pull down to refresh

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.

1. scanPaymentSlice doesn't normalize payment_hash — reconcilePaymentAttempts does.

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.

2. createPaymentScanWindow([]) produces from: Infinity, until: -Infinity.

[].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.