Skip to content

fix(payments): transparent lifecycle durability — no double charges, no lost paid results - #80

Open
ContextVM-org wants to merge 9 commits into
masterfrom
fix/transparent-payment-durability
Open

fix(payments): transparent lifecycle durability — no double charges, no lost paid results#80
ContextVM-org wants to merge 9 commits into
masterfrom
fix/transparent-payment-durability

Conversation

@ContextVM-org

Copy link
Copy Markdown
Contributor

Bundles the three CEP-8 transparent-payment fixes validated against master (see rs-sdk #111 for the reference implementation of the same divergences):

  • Retention split on invoice issuance: post-invoice failures keep the pending entry until TTL, so a spec-blessed redelivery of the same request event cannot mint (and charge) a second invoice. Pre-invoice failures still delete the entry so retries are free.
  • A failed payment_accepted publish no longer aborts the forward; the notification is a SHOULD, the capability result is the point.
  • Pending capacity fails closed: purge expired, then refuse new priced requests (best-effort payment_rejected, no invoice minted) instead of silently evicting a live payment's dedup entry. 0 refuses all.
  • Route survival: the correlation route (+ session) is snapshotted when an invoice is issued; the response router falls back to it on route miss, so paid results survive duplicate-delivery cleanup popping the route and LRU eviction of the idle paying client's session. The dead "recreate session" eviction guard is removed (a synchronous veto would corrupt the session LRU's capacity accounting; snapshots make it unnecessary).

…no lost paid results

Bundles the three CEP-8 transparent-payment fixes validated against
master (see rs-sdk #111 for the reference implementation of the same
divergences):

- Retention split on invoice issuance: post-invoice failures keep the
  pending entry until TTL, so a spec-blessed redelivery of the same
  request event cannot mint (and charge) a second invoice. Pre-invoice
  failures still delete the entry so retries are free.
- A failed payment_accepted publish no longer aborts the forward; the
  notification is a SHOULD, the capability result is the point.
- Pending capacity fails closed: purge expired, then refuse new priced
  requests (best-effort payment_rejected, no invoice minted) instead of
  silently evicting a live payment's dedup entry. 0 refuses all.
- Route survival: the correlation route (+ session) is snapshotted when
  an invoice is issued; the response router falls back to it on route
  miss, so paid results survive duplicate-delivery cleanup popping the
  route and LRU eviction of the idle paying client's session. The dead
  "recreate session" eviction guard is removed (a synchronous veto would
  corrupt the session LRU's capacity accounting; snapshots make it
  unnecessary).
bun 1.4.0 (released Aug 20) regressed WebSocket upgrades made after a
macrotask (oven-sh/bun#39766): server.upgrade() fails on the reconnect
path, so the four relay-reconnection tests time out. Verified on the
PR's base commit 476237b — same code, bun 1.3.14: 20/20 pass; bun
1.4.0: the exact 4 CI failures. The last green master run (Aug 19) predates the release.

Revert to `latest` once a bun release carries oven-sh/bun#39642.
…Event semantics)

bun 1.4.0 corrected CloseEvent reporting (oven-sh/bun#31518): a
frame-based server close now reads as wasClean=true, and a received
1001 is no longer remapped. The mock relay's pause()/stop() relied on
the old behavior — close(1011)/close(1001) were observed as unclean,
which is what applesauce-relay keys reconnects on. On 1.4.0 those
closes read as clean, so pools never reconnected after a simulated
outage and the four relay reconnection tests timed out.

Use ServerWebSocket.terminate() instead: a hard reset with no close
frame (1006, wasClean=false) on every bun version — and the faithful
simulation of a dead relay anyway. Verified on both runtimes: the four
affected tests and the full relay/transport/payments suites (483
tests) pass on bun 1.3.14 and 1.4.0.
…on-positive TTLs

Bundles the two remaining verified findings from the rs-sdk #111/#112
review round:

Writer leak on the gating drop path. Every inbound tools/call with a
progressToken reserves an OpenStreamWriter before middleware runs. A
request dropped by middleware (explicit gating -32042/-32043/-32000,
transparent payment_rejected, capacity refusal, or any custom gating)
never produces the normal-path response that is the only reaper for
never-started writers, so the reservation (writer + closures + abort
controller + metadata) leaked until transport teardown — one per gated
streaming invocation on exactly the path a busy paid API hammers. A
throwing middleware chain leaked the correlation route too. Fix:
cleanupDroppedRequest is now the single owner of per-request state and
releases the writer alongside the route; the chain-error path runs the
same cleanup; the release logic is extracted as
ServerOpenStreamFactory.releaseUnusedWriter and reused by
deferIfStreamActive's never-started branch.

Non-positive TTLs. paymentTtlMs: 0 (both lifecycles) and invoice
ttl: 0 (explicit gating grant) birth-expired the pending/grant entry
while the invoice stayed payable, disarming the redelivery dedup
(CEP-8). Both now fall back to the default window, mirroring the guard
getVerificationTimeoutMs already applied.

Also documents the run-to-completion atomicity assumption of
claim()+trySetPending() for porters (insight from the rs port).
…ures, harden verify cleanup

Three verified items from the consolidated rs-sdk CEP-8 analysis:

Double registration double-charges. withServerPayments never failed and
addInboundMiddleware pushes, so a second call on the same transport
registered a second middleware pair with its own dedup closures — every
priced request minted two invoices and double-charged. The registration
entry point now refuses with an error (WeakSet keyed by transport).

Client handler-failure hangs the request. In the transparent client
lifecycle, paymentPolicy and canHandle declines synthesize a -32000
decline that resolves the pending MCP request, but a handler.handle()
throw only rethrew to onerror and synthesized nothing — the request
hung until the server TTL. Handler crashes now synthesize the same
decline error (still surfacing on onerror for observability).

Crashed verify leaves pending until TTL. The detached explicit-gating
verification task's outer catch only logged; a store failure inside the
inner catch could pin the identity as pending for the whole TTL,
answering every retry with -32043. The outer catch now clears the
pending identity (idempotent on the normal path).
…ent id

The inbound coordinator rewrites request ids to the Nostr event id so
responses from the single shared McpServer are routable when clients'
JSON-RPC id spaces collide. The normal response exit path restores the
client's original id from the correlation route before publishing; the
targeted-response exit path used by -32042/-32043 gating errors skipped
that restore, leaking the internal routing key into the JSON-RPC id on
the wire.

routeTargeted now applies the same restore. Non-breaking for SDK users
(the client transport restores ids independently and ignores the wire
value); aligns the wire with JSON-RPC 2.0 and CEP-8's examples, and
with rs-sdk servers. Falls back to today's behavior when no route is
recorded (redelivery edge).
…and disambiguate retry state

Four client-side defects reported by the rs-sdk maintainers, verified
against this branch (double-wrap and retry_after:0 event-id collision
confirmed with runtime repros):

1. withClientPayments had no re-wrap guard. A chained double wrap ran
   two payment pipelines with separate dedup sets, so one
   payment_required paid twice (the outer wrap also lost the event-id
   context: requestEventId 'unknown', no pending lookup, no decline
   synthesis). A sibling wrap silently replaced the first wrapper's
   trampolines. Both configurations now fail fast via a module-level
   WeakSet, mirroring the server-side registration guard.

2. A -32043 with retry_after: 0 re-sent the cached request on the next
   tick. When the request was not the session's first send (tags
   already latched), the retry was byte-identical to the original
   event - same content, tags, and second-resolution created_at - so
   relays and servers swallowed it as a duplicate and the pending
   cycle stalled until TTL. Retries are now floored at
   minRetryDelayMs (default 1s).

3. retryCounts was an unbounded closure Map; abandoned requests leaked
   their counters forever. Now an LruCache(1000), bounded together
   with the raw-request cache.

4. rawRequestCache keyed by String(id), so numeric 5 and string "5"
   shared one entry and a -32043 answered to one retried the other's
   payload. Both maps now key by JSON.stringify(id).
The onmessageWithContext context shape was declared three times
(NostrClientTransport inline, client-payments.ts, its test), and the
duplicates already drifted once — the double-wrap defect hinged on
exactly this property. Export TransportWithContext and MessageContext
from nostr-client-transport.ts and consume them everywhere.

Type-only change; no runtime diff.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant