Skip to content

feat(payments): gate priced invocations behind the transparent payment lifecycle - #111

Open
harsh04044 wants to merge 1 commit into
ContextVM:mainfrom
harsh04044:feat/cep8-transparent-middleware
Open

feat(payments): gate priced invocations behind the transparent payment lifecycle#111
harsh04044 wants to merge 1 commit into
ContextVM:mainfrom
harsh04044:feat/cep8-transparent-middleware

Conversation

@harsh04044

Copy link
Copy Markdown

Part of #100

Seventh piece of CEP-8, after the targeted sender in #109 and the handshake fix in #110, and the first one where money moves. A priced invocation now triggers notifications/payment_required, waits for the processor to verify settlement, emits notifications/payment_accepted, and only then reaches the MCP handler. A pricing callback can reject (emits notifications/payment_rejected, drops) or waive (forwards untouched). Duplicate deliveries of one request event share one payment. And because a payment can outlive the 60 s stale-route sweep, the transport captures the request's routing fields when the invoice goes out and delivers the eventual result from that capture.

Nothing registers itself yet: the middleware is exported and wired up only by tests. Registration, explicit gating, and client-side auto-pay are later PRs.

What's here

  • src/payments/server_payments.rs: options, the factory returning Arc<dyn InboundMiddleware>, the pending-payment dedup, the lifecycle.
  • src/payments/server_payments_utils.rs (crate-private): capability matching, PMI selection, timeout arithmetic, resolve-and-initiate. The explicit-gating middleware will share it.
  • On the transport: payment_notification_sender(snapshot_ttl) returning the notification publish as a closure for the detached middleware, the route-snapshot map, and a route-miss fallback in send_response that delivers a paid response after the sweep.
  • On the seam: InboundContext gains a per-event cancel token (child of the transport's shutdown token), and the drop-cleanup now releases the open-stream writer slot a gated request reserved.
  • 63 tests: 45 unit (both feature configs, local doubles), 11 in the new tests/payments_transparent_e2e.rs, 7 in-crate.

Two deliberate divergences from ts

  1. Retention splits on whether an invoice exists. ts deletes the pending entry on every failure, so a client that paid an invoice whose verification timed out gets re-invoiced on redelivery and can be charged twice. Here a failure before any invoice deletes the entry (the retry is free); once an invoice is out, every outcome keeps it until TTL, because the client's money may already be gone. The hook is the spec's MUST NOT charge twice for the same transparent request event.
  2. A verified payment forwards unconditionally. ts lets a failed payment_accepted publish abort the forward, which is paid-but-undelivered. Here the failure is logged and the forward happens anyway: the notification is a SHOULD, the result is the point, and the failure is reachable (a paying client is idle by definition and the session LRU evicts idle sessions). The site carries a comment telling future refactors not to tidy it back into an early return.

Both look worth reporting upstream as ts defects.

Why it's built this way

  • The dedup is one critical section. The lifecycle future is built inert, lookup and insert happen under one lock acquisition, and the guard drops before any await. Splitting lookup from insert double-charges under concurrent duplicates; holding the guard across the verify serializes every priced request behind one payment (no clippy lint catches that, so a wall-time overlap test does). The error-path pop is identity-checked, the capacity path evicts only an expired entry and refuses when all are live, and a panicking processor is caught and classified by invoice existence.
  • The snapshot fallback sits at the route miss, not beside the open-stream deferral. A priced tools/call with a progressToken that never streams takes the deferral's passthrough branch with its slot already deleted, so a fallback beside those arms would never run for it. When both a slot and a snapshot exist the open-stream arm wins and the snapshot is consumed unused. The same snapshot also covers a route popped by a duplicate delivery, a loss path ts shares.
  • The sender threads the wrap kind instead of looking it up. It outlives the route; a post-sweep lookup falls back to session state and can answer a persistent-wrap request with an ephemeral wrap, which a briefly-offline client never sees. Hence four arguments where the targeted sender has three.
  • send_notification's body moved into a shared static that both the method and the injected sender call, so the two cannot drift on tags or wrap-kind policy.

Heads up

Like the targeted sender, the closure captures the discovery tag set when built: build it after setting the announcement tags. Pass the middleware's payment TTL as snapshot_ttl so snapshots outlive every payment (delivery is session-independent, so this works past the session timeout). And one finding outside this PR's scope: the client transport consumes its response-correlation entry on the first correlated message of any kind, so payment_required makes our own client drop the acceptance and the paid response. Server-side delivery is correct and asserted on the wire; the fix is recorded for the client payments PR, which without it never sees a paid result.

Declared and bounded rather than fixed: the dedup is TTL-bounded while the spec's MUST is unbounded (ts identical; the options doc says so); a verify timeout emits nothing (ts parity, the spec has no signal for it); a waiver's metadata is dropped (ts behavioral parity, against its own docs); a stored priced request can be replayed by a third party after TTL, so the future auto-payer must ignore invoices for requests it has no live pending entry for; oversized priced requests always take the first-processor fallback because our clients put pmi tags on the start frame while identity comes from the end frame; and the snapshot delivery inherits the deferred path's no-fragmentation gap, now reachable for a paid result.

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