Skip to content

[chain] Add payment-channels - #26

Open
BrendanChou wants to merge 12 commits into
mainfrom
sessions-demo
Open

[chain] Add payment-channels#26
BrendanChou wants to merge 12 commits into
mainfrom
sessions-demo

Conversation

@BrendanChou

@BrendanChou BrendanChou commented Jun 29, 2026

Copy link
Copy Markdown

What & why

Adds unidirectional payment channels so the chain can settle streaming micropayments without a transaction per payment. A payer escrows funds into a channel once, streams signed cumulative vouchers off-chain, and a single on-chain close settles the latest voucher. On-chain throughput scales with channel lifecycles, not payment volume.

The full loop runs live in the demo: a channel operator service accepts vouchers over HTTP and settles on-chain, the spammer drives concurrent channel lifecycles alongside transfer traffic, and the explorer renders every operation kind.

The loop is also demonstrable interactively, x402-style: the operator's GET /stream sells an essay token by token over SSE, streaming only while the channel's debt stays under an advertised credit window (missing/unregistered channel ⇒ 402 Payment Required), and the explorer's paid stream view drives the whole lifecycle from the browser — the passkey wallet funds a WebCrypto ed25519 session key (vouchers are ed25519-only), the session key opens and registers a channel to the operator-as-payee, vouchers auto-sign as the text streams, a "stop paying" toggle shows enforcement (pause at the limit, hang-up after grace), and one settle collapses the session into a single close.

Design

A channel is an ordinary account at a derived address — no new state type, QMDB schema, or header field, and the transfer execution fast-path is untouched (channel ops run in a separate lane).

The address commits to all participants: H(domain || payer || receiver || operator || open_nonce).

  • Operator ≠ receiver (x402 / machine-payment style). The operator is the only key that can close the channel; the settled cumulative is always paid to the receiver, and the remainder refunds the payer. A payee can therefore delegate settlement without handing over its key — the receiver never signs anything and can even be keyless. A payee that settles for itself just names itself (operator == receiver). Because vouchers sign the address, they commit to the full participant tuple with no extra fields, and closes stay permissioned (nobody can burn a receiver's newer vouchers by settling a stale one).
  • The open_nonce makes every channel unique and never-recurring: settled channels are deleted (no leftover state), and an old voucher can never be replayed against a new channel.
  • Expiry lives in the channel account's otherwise-unusable nonce slot. Past it, the payer reclaims the escrow unilaterally with TimeoutChannel; a close landing first wins. This is the payer's escape hatch if the operator never settles.
  • Mint is the chain's only token source. Accounts start empty and mint what they need — permissionless but capped per transaction (MAX_MINT_AMOUNT), so block throughput bounds supply growth.
  • One credit policy: every balance credit saturates at u64::MAX (executor::saturating_credit, shared by the transfer and channel lanes). Because minting is permissionless, a third party can push any account toward the cap; a checked credit would turn that into a grief vector — any transfer batch paying the saturated account would be rejected whole (an empty block), and every close paying a saturated receiver would fail forever. The saturated excess is forfeited, which costs nothing on a chain where anyone can mint. Debits stay checked: overflowing a debit total requires the sender to sign transfers no balance can cover, so rejection only punishes the signer.

Accepted tradeoffs: anyone may pay into a channel address (stray pay-ins become escrow refunded to the payer), and cross-lane conflicts favor the transfer lane — a transfer that writes any account a channel op touches evicts that op from the proposal. On a feeless chain a third party could exploit this by spamming transfers at a channel's receiver every block, starving the operator's close until expiry and voiding the receiver's vouchers. Acceptable for a demo; the mitigation (prioritizing the channel op over the conflicting transfer at proposal) is mechanical.

Changes

  • primitivesTransaction generalizes to a tagged Operation enum (Transfer | OpenChannel | CloseChannel | TimeoutChannel | Mint); shared channel module (channel_address, Voucher, verify_voucher) used identically by chain and operator; operator_api HTTP wire types shared by operator and clients.
  • application — channel execution lane: open escrows, close verifies the voucher and pays receiver/payer, timeout reclaims past expiry; all three delete-on-settle. Blocks run both lanes concurrently against block-start state; verification rejects cross-lane account conflicts, while the proposer drops just the conflicting channel ops. OperatorService: registration verified against finalized state (QMDB inclusion proof), voucher metering, nonce window for pipelined closes, expiry sweep, and a settle/abandon race so a close that can't finalize before expiry burns its nonce instead of wedging.
  • operator (new binary) — HTTP surface (/public-key, /channels, /vouchers, /settle) over OperatorService, wired into local/remote deploy. /public-key advertises the operator's expiry margins (min_runway, settle_margin) so clients derive channel expiries from the real configuration instead of agreeing by convention. The demo operator charges no per-voucher fee: it serves any validly signed voucher that strictly increases the cumulative within the deposit. Registration state is typed defensively: NonZeroU64 deposits end-to-end (the chain already decodes them as non-zero), one RegisteredChannel record per channel (metadata + latest-voucher meter in a single map), full verified-metadata comparison on replayed registrations, and a replay fast path that answers an exact-match re-registration without re-verifying against the chain. Settlement is not cancellation-safe by design (it holds a reserved nonce across awaits), so both callers route through one owned-task helper (spawn_settlement).
  • spammer — channel lifecycles alongside transfers: warm-up mints, concurrent lifecycle pool, deliberate timeout exercises, and a reclaim queue so failed lifecycles never strand deposits. Payouts go to keyless derived receivers, exercising the delegated topology live. Channel expiries derive from the operator's advertised margins; transfer/channel seed ranges are assert-guarded against overlap.
  • paid-stream demo — the operator's /stream SSE endpoint meters a fixed essay against each channel's credit (OperatorService::consume applies the same gates as voucher serving plus the credit policy: never past paid + debt_limit, never past the deposit); pricing (price_per_token, debt_limit) is advertised on /public-key next to the margins. The explorer gains the browser client: an OpenChannel encoder, channel-address/voucher signing with commonware's union_unique namespace framing, a localStorage-persisted WebCrypto ed25519 session key, pure (node-tested) meter/top-up/expiry logic, and the PaidStreamPage view. The fund and open transactions are deliberately sequential — they write the same account across lanes and would conflict in one block. wire.json now pins the channel-address derivation and both ed25519 signing paths via deterministic signature reproduction.
  • indexer + explorer — per-kind activity rows (kind column), an explicit deleted marker column on account_meta rows (a deleted account is never confusable with a genuinely zero one), explorer renders direction/kind and the wallet gains a mint button. Golden fixtures (wire.json, sql.json) pin the Rust↔TypeScript wire and SQL contracts in CI on both sides.

Tests

277 passing, just lint clean, explorer TS suites green (44). Highlights: deterministic full-lifecycle tests over the real propose/execute/finalize path (open → off-chain vouchers → one close, exact balances, channel deleted); replay/forgery/over-claim rejection; timeout-vs-close race; operator lifecycle against a live chain including the abandon race and a lost-acknowledgement settlement; delegated settlement paying a keyless receiver; cross-lane conflict dropping only the conflicting op; saturating credits on both lanes (discrete and contended transfer paths, close/timeout/mint). Stream metering: pause at the debt limit, voucher resume, deposit cap, refusal after settlement, all against the live-chain harness. Wire/SQL fixtures assert byte-level codec agreement from both languages, including byte-identical ed25519 voucher/transaction signatures re-signed from TypeScript.

Compatibility

Breaking wire-format change, no migration — requires a fresh genesis (wipe local/ before running the demo). Appropriate for an example chain.

Operator threat model

The operator binary is load-test infrastructure and must not custody real value. Two documented holes, both acceptable only under that posture: channel registrations (and their latest-voucher accounting) are in-memory, so a restart allows a settled channel to be re-registered and its old vouchers replayed for free service; and nonce recovery reads committed rather than finalized state, so a crash inside the commit lag can resume on a consumed nonce (the exclusion checks prevent a wedge, but the settlement is misreported). A real deployment needs a durable channel store (or a state existence check at registration) and finalized-state reads.

Deferred

  • Operator fees: the operator earns nothing as settler — closes carry no on-chain fee and vouchers carry no per-voucher toll. (The paid-stream demo prices its own resource — tokens of content — and that revenue goes to the channel's receiver; in the demo topology the operator is also the receiver, but the settling role itself is still unpaid.) Follow-up: a settler fee fixed at open, inside the address derivation.
  • Operator scaling: the channels map grows without eviction, stats()/due_settlements() scan it under the one mutex that also serializes voucher verification (~15–30k vouchers/s ceiling). Follow-up: incremental counters, an expiry index, eviction, verify-outside-lock.
  • Mempool batch statuses don't distinguish "judged and dropped at height H" from "never judged", so a spammer payer whose opens keep getting filtered burns a nonce per failed lifecycle (commented at the submit site; deposits are still reclaimed via timeout).
  • Mint executes in the sequential channel lane, so the spammer's bulk warm-up mints don't parallelize (a one-time startup cost), and a pre-funded account submitting a mint and a transfer in the same block loses the mint to the cross-lane conflict drop. Follow-up: parallelize mint application within the lane.

Loading
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants