[chain] Add payment-channels - #26
Open
BrendanChou wants to merge 12 commits into
Open
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 /streamsells 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). 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).open_noncemakes 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.TimeoutChannel; a close landing first wins. This is the payer's escape hatch if the operator never settles.Mintis 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.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
Transactiongeneralizes to a taggedOperationenum (Transfer | OpenChannel | CloseChannel | TimeoutChannel | Mint); sharedchannelmodule (channel_address,Voucher,verify_voucher) used identically by chain and operator;operator_apiHTTP wire types shared by operator and clients.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./public-key,/channels,/vouchers,/settle) overOperatorService, wired into local/remote deploy./public-keyadvertises 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:NonZeroU64deposits end-to-end (the chain already decodes them as non-zero), oneRegisteredChannelrecord 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)./streamSSE endpoint meters a fixed essay against each channel's credit (OperatorService::consumeapplies the same gates as voucher serving plus the credit policy: never pastpaid + debt_limit, never past the deposit); pricing (price_per_token,debt_limit) is advertised on/public-keynext to the margins. The explorer gains the browser client: an OpenChannel encoder, channel-address/voucher signing with commonware'sunion_uniquenamespace framing, a localStorage-persisted WebCrypto ed25519 session key, pure (node-tested) meter/top-up/expiry logic, and thePaidStreamPageview. The fund and open transactions are deliberately sequential — they write the same account across lanes and would conflict in one block.wire.jsonnow pins the channel-address derivation and both ed25519 signing paths via deterministic signature reproduction.kindcolumn), an explicitdeletedmarker column onaccount_metarows (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 lintclean, 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
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.Mintexecutes 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.