I read the source (v0.4.31, all of packages/js/core and packages/js/http) and re-implemented the crypto checks independently. The threat model holds up. Two things in the public API don't.
Both are exported from @openreceive/core (index.ts:74). In payments.ts the attempt hash goes through normalizePaymentHash() — trim().toLowerCase(), then a /^[0-9a-f]{64}$/ check that throws RangeError. In payment-scan.ts:79 it's just:
const expected = new Set(window.attempts.map((attempt) => attempt.payment_hash));
and the comparison is tx.payment_hash?.toLowerCase(). So an attempt whose payment_hash carries an uppercase hex digit (or a leading space) never matches the wallet row. No error, no not_found, no transition — the attempt stays pending and is re-scanned forever. ScanAttempt (payment-scan.ts:6) documents no case requirement, and neither function appears in api_docs.md, so a host calling the exported slice has nothing telling them to lowercase first.
The mounted HTTP path is safe today only because paymentInsert lowercases on write (payment-repository.ts:275) and reconcile-gate.ts:180 passes that stored value straight through. That's an invariant held by a caller three layers away, not by the function that depends on it. One toLowerCase() at the expected construction closes it.
[].every(...) is true, so authoritative is true, and then Math.min(...[]) is Infinity and Math.max(...[]) is -Infinity. The window comes back with from: Infinity. Feed that to scanPaymentSlice and normalizeUnix throws RangeError: from must be a non-negative safe integer — a confusing error for what is really "no attempts". Unreachable from reconcile-gate.ts today (the cohort !== undefined guard at line 197 and splitWindow's timestamps.length < 2 return both prevent it), but it's a public export with no guard and no test. An early if (attempts.length === 0) return { attempts: [], from: 0, ... } would make the contract total.
What I verified and could not break. I re-implemented Keccak-256 from the spec and ran the eight official EIP-55 vectors from the EIP: 8/8 recompute to themselves and isValidEthereumAddress accepts all eight, and a single flipped case bit is rejected. TRON Base58Check: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t decodes to 25 bytes, prefix 0x41, double-SHA-256 tail matches, re-encodes identically. Solana: the all-1 System Program address decodes to exactly 32 zero bytes — the bytes: number[] = [] comment in decodeBase58 is a real bug that was actually fixed, not a comment describing an aspiration.
What's genuinely good, and rare. Preflight fails closed on a spend-capable connection and caches the summary only after every refusal clears (alby-nwc.ts:262), so a caller can't swallow the refusal and retry make_invoice. Settlement is write-once with assertSettlementCommitted re-reading the row after the write. The rate limiter refuses to construct without a durable counter rather than falling back to per-process memory. reconciliationTransition waits for a scan past expiry+900s instead of trusting the local clock. And all 89 links in llms.txt return 200 with real markdown — I checked every one, which is not something I can usually say about an agent-facing index.
One thing worth adding to security.md: it says a leaked receive-only code lets an attacker "read your payment history", which is honest, but doesn't say how much — list_transactions returns outgoing rows too, so a compromised web box sees the merchant's full wallet activity, not just inbound invoices. That's a bigger blast radius than "read your history" implies, and it's the argument for a dedicated NWC connection per deployment rather than reusing one.
Nice work. The two API gaps are small fixes; the rest is more rigor than most payment libraries ship with.
I read the source (v0.4.31, all of
packages/js/coreandpackages/js/http) and re-implemented the crypto checks independently. The threat model holds up. Two things in the public API don't.1.
scanPaymentSlicedoesn't normalizepayment_hash—reconcilePaymentAttemptsdoes.Both are exported from
@openreceive/core(index.ts:74). Inpayments.tsthe attempt hash goes throughnormalizePaymentHash()—trim().toLowerCase(), then a/^[0-9a-f]{64}$/check that throwsRangeError. Inpayment-scan.ts:79it's just:const expected = new Set(window.attempts.map((attempt) => attempt.payment_hash));and the comparison is
tx.payment_hash?.toLowerCase(). So an attempt whosepayment_hashcarries an uppercase hex digit (or a leading space) never matches the wallet row. No error, nonot_found, no transition — the attempt stayspendingand is re-scanned forever.ScanAttempt(payment-scan.ts:6) documents no case requirement, and neither function appears inapi_docs.md, so a host calling the exported slice has nothing telling them to lowercase first.The mounted HTTP path is safe today only because
paymentInsertlowercases on write (payment-repository.ts:275) andreconcile-gate.ts:180passes that stored value straight through. That's an invariant held by a caller three layers away, not by the function that depends on it. OnetoLowerCase()at theexpectedconstruction closes it.2.
createPaymentScanWindow([])producesfrom: Infinity, until: -Infinity.[].every(...)istrue, soauthoritativeistrue, and thenMath.min(...[])isInfinityandMath.max(...[])is-Infinity. The window comes back withfrom: Infinity. Feed that toscanPaymentSliceandnormalizeUnixthrowsRangeError: from must be a non-negative safe integer— a confusing error for what is really "no attempts". Unreachable fromreconcile-gate.tstoday (thecohort !== undefinedguard at line 197 andsplitWindow'stimestamps.length < 2return both prevent it), but it's a public export with no guard and no test. An earlyif (attempts.length === 0) return { attempts: [], from: 0, ... }would make the contract total.What I verified and could not break. I re-implemented Keccak-256 from the spec and ran the eight official EIP-55 vectors from the EIP: 8/8 recompute to themselves and
isValidEthereumAddressaccepts all eight, and a single flipped case bit is rejected. TRON Base58Check:TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6tdecodes to 25 bytes, prefix0x41, double-SHA-256 tail matches, re-encodes identically. Solana: the all-1System Program address decodes to exactly 32 zero bytes — thebytes: number[] = []comment indecodeBase58is a real bug that was actually fixed, not a comment describing an aspiration.What's genuinely good, and rare. Preflight fails closed on a spend-capable connection and caches the summary only after every refusal clears (
alby-nwc.ts:262), so a caller can't swallow the refusal and retrymake_invoice. Settlement is write-once withassertSettlementCommittedre-reading the row after the write. The rate limiter refuses to construct without a durable counter rather than falling back to per-process memory.reconciliationTransitionwaits for a scan past expiry+900s instead of trusting the local clock. And all 89 links inllms.txtreturn 200 with real markdown — I checked every one, which is not something I can usually say about an agent-facing index.One thing worth adding to
security.md: it says a leaked receive-only code lets an attacker "read your payment history", which is honest, but doesn't say how much —list_transactionsreturns outgoing rows too, so a compromised web box sees the merchant's full wallet activity, not just inbound invoices. That's a bigger blast radius than "read your history" implies, and it's the argument for a dedicated NWC connection per deployment rather than reusing one.Nice work. The two API gaps are small fixes; the rest is more rigor than most payment libraries ship with.