@bsv/payment-express-middleware
Version:
BSV Blockchain service monetization express middleware
234 lines (233 loc) • 9.17 kB
JavaScript
import { Beef, PublicKey, Utils, createNonce, verifyNonce } from "@bsv/sdk";
//#region src/index.ts
const PAYMENT_VERSION = "1.0";
const DEFAULT_MAX_PAYMENT_HEADER_BYTES = 64 * 1024;
const DEFAULT_REPLAY_CAPACITY = 1e5;
const MAX_NONCE_LENGTH = 512;
var InMemoryPaymentReplayStore = class {
maxEntries;
claimed = /* @__PURE__ */ new Set();
constructor(maxEntries = DEFAULT_REPLAY_CAPACITY) {
this.maxEntries = maxEntries;
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new RangeError("Replay-store capacity must be a positive safe integer.");
}
claim(transactionId) {
if (this.claimed.has(transactionId)) return false;
if (this.claimed.size >= this.maxEntries) throw new Error("Payment replay store capacity exceeded.");
this.claimed.add(transactionId);
return true;
}
};
function isPositiveSafeInteger(value) {
return Number.isSafeInteger(value) && value > 0;
}
function isCanonicalBase64(value) {
if (value.length === 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false;
try {
return Utils.toBase64(Utils.toArray(value, "base64")) === value;
} catch {
return false;
}
}
function isCompressedPublicKey(value) {
if (!/^(02|03)[0-9a-fA-F]{64}$/.test(value)) return false;
try {
return PublicKey.fromString(value).toString() === value.toLowerCase();
} catch {
return false;
}
}
function paymentHeader(req) {
const value = req.headers["x-bsv-payment"];
if (value === void 0) return void 0;
return typeof value === "string" ? value : null;
}
function parsePaymentHeader(raw, maxBytes) {
if (Buffer.byteLength(raw, "utf8") > maxBytes) return void 0;
let value;
try {
value = JSON.parse(raw);
} catch {
return;
}
if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
const record = value;
if (typeof record.derivationPrefix !== "string" || typeof record.derivationSuffix !== "string" || typeof record.transaction !== "string" || record.derivationPrefix.length > MAX_NONCE_LENGTH || record.derivationSuffix.length > MAX_NONCE_LENGTH || !isCanonicalBase64(record.derivationPrefix) || !isCanonicalBase64(record.derivationSuffix) || !isCanonicalBase64(record.transaction)) return;
return {
derivationPrefix: record.derivationPrefix,
derivationSuffix: record.derivationSuffix,
transaction: record.transaction
};
}
function parseAtomicPayment(payment, requiredSatoshis) {
try {
const transaction = Utils.toArray(payment.transaction, "base64");
const beef = Beef.fromBinary(transaction);
const transactionId = beef.atomicTxid;
if (typeof transactionId !== "string") return void 0;
const satoshis = (beef.findTxid(transactionId)?.tx)?.outputs[0]?.satoshis;
if (typeof satoshis !== "number" || satoshis < requiredSatoshis) return void 0;
return {
payment,
transaction,
transactionId,
satoshis
};
} catch {
return;
}
}
function sendError(res, status, code, description, details = {}) {
res.status(status).json({
status: "error",
code,
...details,
description
});
}
function safeErrorContext(error) {
return error instanceof Error ? { errorName: error.name } : { errorType: typeof error };
}
function isPaymentLogger(value) {
if (value === void 0) return true;
if (value === null || typeof value !== "object") return false;
const logger = value;
return (logger.error === void 0 || typeof logger.error === "function") && (logger.warn === void 0 || typeof logger.warn === "function");
}
async function issuePaymentChallenge(wallet, res, requestPrice, logger) {
try {
const derivationPrefix = await createNonce(wallet);
res.status(402).set({
"x-bsv-payment-version": PAYMENT_VERSION,
"x-bsv-payment-satoshis-required": String(requestPrice),
"x-bsv-payment-derivation-prefix": derivationPrefix
}).json({
status: "error",
code: "ERR_PAYMENT_REQUIRED",
satoshisRequired: requestPrice,
description: "A BSV payment is required. Provide the X-BSV-Payment header."
});
} catch (error) {
logger?.error?.("Failed to create a payment challenge.", safeErrorContext(error));
sendError(res, 503, "ERR_PAYMENT_UNAVAILABLE", "Payment processing is temporarily unavailable.");
}
}
/**
* Creates middleware that enforces a BRC-29 wallet payment after BRC-103 auth.
*/
function createPaymentMiddleware(options) {
if (options === null || typeof options !== "object") throw new TypeError("Payment middleware options are required.");
const { calculateRequestPrice = () => 100, wallet, replayStore = new InMemoryPaymentReplayStore(), maxPaymentHeaderBytes = DEFAULT_MAX_PAYMENT_HEADER_BYTES, logger } = options;
if (typeof calculateRequestPrice !== "function") throw new TypeError("The calculateRequestPrice option must be a function.");
if (wallet === null || typeof wallet !== "object" || typeof wallet.internalizeAction !== "function") throw new TypeError("A valid wallet instance must be supplied to the payment middleware.");
if (replayStore === null || typeof replayStore.claim !== "function") throw new TypeError("A replay store with an atomic claim method is required.");
if (!Number.isSafeInteger(maxPaymentHeaderBytes) || maxPaymentHeaderBytes < 1) throw new RangeError("maxPaymentHeaderBytes must be a positive safe integer.");
if (!isPaymentLogger(logger)) throw new TypeError("logger error and warn properties must be functions when provided.");
return async (req, res, next) => {
const paymentRequest = req;
const identityKey = paymentRequest.auth?.identityKey;
if (typeof identityKey !== "string" || !isCompressedPublicKey(identityKey)) {
sendError(res, 500, "ERR_SERVER_MISCONFIGURED", "The payment middleware must run after successful Auth middleware.");
return;
}
let requestPrice;
try {
requestPrice = await calculateRequestPrice(paymentRequest);
} catch (error) {
logger?.error?.("Payment pricing failed.", safeErrorContext(error));
sendError(res, 500, "ERR_PAYMENT_INTERNAL", "An internal error occurred while determining the payment required for this request.");
return;
}
if (requestPrice === 0) {
paymentRequest.payment = {
satoshisPaid: 0,
accepted: true,
tx: "",
txid: ""
};
next();
return;
}
if (!isPositiveSafeInteger(requestPrice)) {
logger?.error?.("Payment pricing returned an invalid value.", { requestPrice });
sendError(res, 500, "ERR_PAYMENT_INTERNAL", "The configured payment price is invalid.");
return;
}
const rawPayment = paymentHeader(paymentRequest);
if (rawPayment === void 0) {
await issuePaymentChallenge(wallet, res, requestPrice, logger);
return;
}
if (rawPayment === null) {
sendError(res, 400, "ERR_MALFORMED_PAYMENT", "The X-BSV-Payment header is malformed.");
return;
}
const payment = parsePaymentHeader(rawPayment, maxPaymentHeaderBytes);
if (payment === void 0) {
sendError(res, 400, "ERR_MALFORMED_PAYMENT", "The X-BSV-Payment header is malformed.");
return;
}
let validPrefix = false;
try {
validPrefix = await verifyNonce(payment.derivationPrefix, wallet);
} catch (error) {
logger?.warn?.("Payment derivation-prefix verification failed.", safeErrorContext(error));
}
if (!validPrefix) {
sendError(res, 400, "ERR_INVALID_DERIVATION_PREFIX", "The payment derivation prefix is invalid.");
return;
}
const parsed = parseAtomicPayment(payment, requestPrice);
if (parsed === void 0) {
sendError(res, 400, "ERR_INVALID_PAYMENT", "The payment transaction is invalid or does not cover the required amount.");
return;
}
let claimed;
try {
const claimResult = await replayStore.claim(parsed.transactionId);
if (typeof claimResult !== "boolean") throw new TypeError("The replay store returned an invalid claim result.");
claimed = claimResult;
} catch (error) {
logger?.error?.("Payment replay claim failed.", safeErrorContext(error));
sendError(res, 503, "ERR_PAYMENT_UNAVAILABLE", "Payment processing is temporarily unavailable.");
return;
}
if (!claimed) {
sendError(res, 409, "ERR_PAYMENT_REPLAYED", "This payment was already used.");
return;
}
try {
const result = await wallet.internalizeAction({
tx: parsed.transaction,
outputs: [{
paymentRemittance: {
derivationPrefix: payment.derivationPrefix,
derivationSuffix: payment.derivationSuffix,
senderIdentityKey: identityKey
},
outputIndex: 0,
protocol: "wallet payment"
}],
description: "Payment for request"
});
if (result.accepted !== true || result.isMerge === true) {
sendError(res, 409, "ERR_PAYMENT_REPLAYED", "This payment was not newly accepted.");
return;
}
paymentRequest.payment = {
satoshisPaid: parsed.satoshis,
accepted: true,
tx: payment.transaction,
txid: parsed.transactionId
};
res.set({ "x-bsv-payment-satoshis-paid": String(parsed.satoshis) });
next();
} catch (error) {
logger?.warn?.("Payment internalization failed.", safeErrorContext(error));
sendError(res, 400, "ERR_PAYMENT_FAILED", "The payment could not be accepted.");
}
};
}
//#endregion
export { InMemoryPaymentReplayStore, createPaymentMiddleware };
//# sourceMappingURL=index.mjs.map