UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

209 lines • 7.88 kB
import { secp256k1 } from "@noble/curves/secp256k1.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { bech32, utils as scure } from "@scure/base"; const SIGNATURE_WORDS = 104; const TIMESTAMP_WORDS = 7; const DEFAULT_EXPIRY_SECONDS = 3600; const MSAT_PER_BTC = 100000000000n; // Multiplier → divisor of the BTC amount in msat. const MULTIPLIERS = { m: 1000n, // milli: 1e-3 BTC = 1e8 msat u: 1000000n, n: 1000000000n, p: 1000000000000n, }; const TAG = { p: 1, s: 16, d: 13, h: 23, x: 6, n: 19, c: 24, features: 5 }; // BOLT11: a reader MUST fail when a fixed-length field has another length. const FIXED_LENGTHS = new Map([ [TAG.p, 52], [TAG.s, 52], [TAG.h, 52], [TAG.n, 53], ]); // Fields that may appear at most once; BOLT11 asks for minimal integer encodings. const SINGLETON_TAGS = new Set([ TAG.p, TAG.s, TAG.n, TAG.x, TAG.c, TAG.features, ]); const MINIMAL_TAGS = new Set([TAG.x, TAG.c, TAG.features]); /** * Decodes a BOLT11 invoice and verifies its signature. * * Strict: rejects mixed case, a `lightning:` prefix, a missing or zero amount, * sub-millisatoshi amounts, amounts with leading zeros, fixed-length fields of * the wrong length or with non-zero padding, duplicated payment hash, payment * secret, payee, expiry, CLTV, or feature fields, non-minimal integer fields, * a missing payment secret, a high-S signature verified against an `n` field, * and an invalid signature. * * @param invoice - BOLT11 invoice text * @returns The decoded invoice * @throws Error when the invoice is malformed or its signature is invalid */ export function decodeInvoice(invoice) { if (typeof invoice !== "string" || invoice.length === 0) throw new Error("empty invoice"); const { prefix, words } = bech32.decode(invoice, false); const { currency, amountMsat } = parseHrp(prefix); if (words.length < TIMESTAMP_WORDS + SIGNATURE_WORDS) throw new Error("invoice too short"); const signed = words.slice(0, words.length - SIGNATURE_WORDS); const sigBytes = bech32.fromWords(words.slice(words.length - SIGNATURE_WORDS)); const timestamp = wordsToNumber(signed.slice(0, TIMESTAMP_WORDS)); const fields = parseTaggedFields(signed.slice(TIMESTAMP_WORDS)); if (fields.paymentHash === undefined) throw new Error("invoice has no payment hash"); if (!fields.hasPaymentSecret) throw new Error("invoice has no payment secret"); const prefixBytes = new TextEncoder().encode(prefix); const dataBytes = Uint8Array.from(scure.convertRadix2(signed, 5, 8, true)); const message = new Uint8Array(prefixBytes.length + dataBytes.length); message.set(prefixBytes); message.set(dataBytes, prefixBytes.length); return { currency, amountMsat, timestamp, expirySeconds: fields.expiry ?? DEFAULT_EXPIRY_SECONDS, paymentHash: fields.paymentHash, descriptionHashes: fields.descriptionHashes, inlineDescriptionCount: fields.inlineDescriptions, payee: verifySignature(sigBytes, sha256(message), fields.payee), hasPayeeField: fields.payee !== undefined, }; } /** * Parses the human-readable part into currency and millisatoshi amount. * * @param prefix - The bech32 human-readable part * @returns Currency prefix and amount in millisatoshis */ function parseHrp(prefix) { const match = /^ln([a-z]+?)([0-9]+)?([munp])?$/.exec(prefix); if (!match) throw new Error("invalid invoice prefix"); const [, currency, digits, multiplier] = match; if (!digits) throw new Error("invoice has no amount"); if (digits.length > 1 && digits.startsWith("0")) throw new Error("amount has leading zeros"); const value = BigInt(digits); if (value === 0n) throw new Error("invoice amount is zero"); const scaled = value * MSAT_PER_BTC; if (!multiplier) return { currency, amountMsat: scaled }; const divisor = MULTIPLIERS[multiplier]; if (scaled % divisor !== 0n) throw new Error("amount is not an integral millisatoshi"); return { currency, amountMsat: scaled / divisor }; } /** * Reads BOLT11 tagged fields, skipping unknown fields. * * @param words - 5-bit words after the timestamp, before the signature * @returns The fields the scheme consumes */ function parseTaggedFields(words) { const out = { hasPaymentSecret: false, descriptionHashes: [], inlineDescriptions: 0, }; const seen = new Set(); let i = 0; while (i < words.length) { if (i + 3 > words.length) throw new Error("truncated tagged field"); const tag = words[i]; const length = words[i + 1] * 32 + words[i + 2]; const data = words.slice(i + 3, i + 3 + length); if (data.length !== length) throw new Error("truncated tagged field"); i += 3 + length; const fixedLength = FIXED_LENGTHS.get(tag); if (fixedLength !== undefined && length !== fixedLength) { throw new Error("tagged field has the wrong length"); } if (SINGLETON_TAGS.has(tag)) { if (seen.has(tag)) throw new Error("duplicate tagged field"); seen.add(tag); } if (MINIMAL_TAGS.has(tag) && data[0] === 0) throw new Error("non-minimal integer field"); switch (tag) { case TAG.p: out.paymentHash = bytesToHex(bech32.fromWords(data)); break; case TAG.s: bech32.fromWords(data); // rejects non-zero padding out.hasPaymentSecret = true; break; case TAG.h: out.descriptionHashes.push(bytesToHex(bech32.fromWords(data))); break; case TAG.d: out.inlineDescriptions += 1; break; case TAG.n: out.payee = bytesToHex(bech32.fromWords(data)); break; case TAG.x: out.expiry = wordsToNumber(data); break; default: break; } } return out; } /** * Verifies the invoice signature against `n` when present (low-S required, as * BOLT11 demands), otherwise recovers the signing key (either S form accepted). * * @param sigBytes - 65 bytes: compact signature followed by the recovery id * @param digest - SHA-256 of the signed invoice bytes * @param payeeField - Public key from the `n` field, if present * @returns The signing public key, compressed lowercase hex */ function verifySignature(sigBytes, digest, payeeField) { const recovery = sigBytes[64]; if (recovery > 3) throw new Error("invalid recovery id"); const compact = sigBytes.subarray(0, 64); if (payeeField !== undefined) { const valid = secp256k1.verify(compact, digest, hexToBytes(payeeField), { lowS: true, format: "compact", prehash: false, }); if (!valid) throw new Error("invalid invoice signature"); return payeeField; } // Recovery yields the unique key for which this signature verifies. const signature = secp256k1.Signature.fromBytes(compact, "compact").addRecoveryBit(recovery); return signature.recoverPublicKey(digest).toHex(true); } /** * Interprets 5-bit words as a big-endian unsigned integer. * * @param words - 5-bit words * @returns The integer value */ function wordsToNumber(words) { let value = 0; for (const word of words) { value = value * 32 + word; if (!Number.isSafeInteger(value)) throw new Error("integer field too large"); } return value; } //# sourceMappingURL=bolt11.js.map