feat: mpp-channel Anchor program and session method (PR #201 spec)#20
feat: mpp-channel Anchor program and session method (PR #201 spec)#20alexanderattar wants to merge 4 commits into
Conversation
Implements the on-chain payment channel escrow program and aligns the TypeScript session method with PR #201 of the mpp-specs. ## Anchor program (programs/mpp-channel) PaymentChannel account tracks: payer, payee, token mint, authorized signer, deposit, settled amount, grace period, forced-close timestamp, finalization status, and salt. PDA seeds: ["mpp-channel", payer, payee, token, salt_le, authorized_signer] This binds all five spec-required components into the channel address. Instructions: - open: creates channel PDA and transfers SPL tokens into vault ATA - settle: verifies Ed25519 voucher signature on-chain via instructions sysvar introspection, transfers cumulative delta to payee - close: like settle but also refunds remaining deposit to payer and marks channel finalized - top_up: increases vault deposit, resets any pending forced-close - request_close: payer initiates forced close, records timestamp - withdraw: payer recovers deposit after grace period expires On-chain voucher verification parses the raw JCS JSON bytes (the same bytes signed for the HTTP credential) to extract channelId and cumulativeAmount. The Ed25519 precompile is not CPI-callable; the program validates it by reading the instructions sysvar at the index specified in the settle/close args. Program ID: 21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQ (deployed to devnet) ## TypeScript session method Aligned to PR #201 (Ludo Galabru's session spec): - Voucher schema simplified to three fields: channelId, cumulativeAmount, expiresAt - Signing uses raw JCS bytes with no domain separator prefix - Credential actions renamed: update->voucher, topup->topUp - Open/topUp actions carry a partially-signed transaction (pull mode) - Close voucher is optional (supports refund-only cooperative close) - Channel state uses acceptedCumulative/spentAmount (not lastSequence) - Idempotent voucher handling: cumulativeAmount <= acceptedCumulative succeeds silently - Server rejects vouchers when channel has a pending forced close New files: - src/anchor/MppChannelClient.ts: instruction builders and PDA derivation for mpp-channel - src/anchor/TransactionHandler.ts: server-side on-chain transaction verification - src/utils/ed25519.ts: Ed25519 instruction serialization (DATA_START=16, no padding) - src/session/BinaryVoucher.ts: compact 48-byte voucher for on-chain use ## Tests - anchor-channel.test.ts: 5 localnet integration tests covering open, settle with Ed25519 verification, full lifecycle, requestClose/withdraw, and topUp with forced-close cancellation. Starts solana-test-validator with the compiled .so loaded. - binary-voucher.test.ts: round-trip tests for the JCS parser and compact voucher format - session.test.ts: 62 unit tests for server/client session logic - vitest.config.anchor.ts: separate vitest config for anchor tests (120s timeout, single worker, excluded from main test run) ## Devnet verification Program deployed and all instructions exercised with real transactions: Setup: - Fund payee: https://explorer.solana.com/tx/4aenktQ6G5yos1ioZr18cC1RLVu7w3CLEDWdVtMQH8HNjTGcvhaG1yoYNh7ynR6mY6WUkepM4Q9DmVEfhBHFW445?cluster=devnet - Create mint: https://explorer.solana.com/tx/2bSreTn1vyfgYQWoRT4ghyQYC1Bnte2dmUmVjfkgzd1BXrGv5Ab1p312MpGiWBecChuCRE4Yvef55wSY4c2WFKMK?cluster=devnet - Create ATAs: https://explorer.solana.com/tx/4CGn6jmXia2JJQUhmDMzvjKmUnoA5m7x4Ykm5d6TotPmZNNN7dXtg4uVNBG3a84v69GgiWGQA5AFiTLsM4grgjNL?cluster=devnet - Mint tokens: https://explorer.solana.com/tx/2DVvN1QmuMnPfJwqCQUYSjFAniaqoTDB5oP7JjLiQqFPHDetGumNWSgDVJbRPJBbuQnZDymffhfMnzg9z1cRczjd?cluster=devnet Channel lifecycle (open -> settle -> settle -> close): - Open (deposit 1M): https://explorer.solana.com/tx/35rVZ7FVaQHTft3fFrr8YMyjoxcuWCKAqyTs9crW7qnQxXQhj8sgm3XkzyNdCPXpTgN8Vj5BNzG5G26TDEEERwLk?cluster=devnet - Settle 300k: https://explorer.solana.com/tx/5J9YWGkPtp9SRDaFZ7MxUkxf2xZfHz26g8tN1YmBf2hNQFjRPvMk1zbiekRKmxx8nrcJEYQBRwHJzoP6bZKdxh6h?cluster=devnet - Settle 600k: https://explorer.solana.com/tx/3g5GvmQZ7zpZPVXu17LHE6N1RQcgC2gGhFpft1VvLmNRejg6SK8Lur9DNfNWK1cWjLeVzc7PUEQqisyLrNvJQrCc?cluster=devnet - Close 800k: https://explorer.solana.com/tx/2DLg8MicA4PJMv7LJQxeydHVE4RP1mJFmvFD3DznuUKGMFmymjnh6FVA41DJ6gDm1rp2Ew6HPA8SmkeZq1JwGUSx?cluster=devnet Forced close (requestClose -> grace period -> withdraw): - Open (deposit 500k): https://explorer.solana.com/tx/4hcU5ckjRg4BnwFjvPxHmn3Z7B434ndT6Xrd83qshvoogLVY6Z9z5k25M2GsYvD4u1SLneTGkbnoQY1vh3UtqGuK?cluster=devnet - Request close: https://explorer.solana.com/tx/3hxMmZV74WbvRDVUGCombfBEMP856T5JZMp5YvhvZMRgpCTwx9AZ5MY54nxNb3ARCCFXeZ9S3NTwcyaNJPgBFSrz?cluster=devnet - Withdraw: https://explorer.solana.com/tx/5isuUTzhaLad422pU8m24UK2wn4dJa7fXBDZTRK4fEN1zVmUGTUYgMMZrXqQwEFm1zuL92gLwQJQ25EwnsHPVzFT?cluster=devnet Balances verified: payee received exactly 800,000 tokens, payer holds 9,200,000 (started 10M, deposited 1M, refunded 200k). Forced close recovered the full 500,000 deposit.
Five blockers: - TransactionHandler now verifies the Anchor discriminator, deposit/amount via u64 LE, payee (accounts[1]), and mint (accounts[2]) instead of only checking that the channel program appeared in the transaction. DISCRIMINATOR_OPEN and DISCRIMINATOR_TOP_UP are now exported from MppChannelClient so TransactionHandler can import them. - ChannelStore.fromStore() documents that the in-process Map lock is single-instance only; notes what a distributed-safe implementation needs (Redis WATCH/MULTI/EXEC or Lua CAS). - assertSignerAuthorized removes the `|| signer !== channel.payer` fallback. For standard channels authorizedSigner IS the payer (set at open time); accepting the payer as an alternative bypass silently widened trust. Added comment explaining the delegated-key model. - closeTx removed from the model entirely: Types.ts, UnboundedAuthorizer, SwigBudgetAuthorizer, SwigSessionAuthorizer, makeSessionAuthorizer. The close path is server-initiated; clients never submit a close transaction. - handleVoucher now distinguishes equal (idempotent retry, return cached result) from less-than (error: "Voucher cumulative amount must not decrease"). Previously both cases returned success, letting stale replays authorize service without new value. Three secondary: - BinaryVoucher.ts and its tests deleted (48-byte format superseded by JCS JSON signing). Export removed from session/index.ts. - programs/mpp-channel/src/binary_voucher.rs renamed to jcs_voucher.rs; imports updated in settle.rs, close.rs, and lib.rs to reflect that the file parses JCS JSON, not a binary format. - BudgetAuthorizer renamed to SwigBudgetAuthorizer everywhere (class, interface, filename, exports) to surface the Swig dependency. native SOL rejected at session init with a clear error; state.rs comment updated to remove the misleading "or system program for native SOL" note.
…a instructions verifyOpenInstruction now checks accounts[3] (channel PDA) against the session channelId. verifyTopUpInstruction checks accounts[1] (channel PDA). Both handlers stop ignoring the channelId argument. findChannelInstruction switched from find() to filter() with an explicit length assertion. Transactions containing more than one channel-program instruction are now rejected outright — a mixed tx with top_up followed by request_close can no longer slip through by matching only on the first ix.
|
Hey @lgalabru, I noticed you've done a lot of work on this repo since I opened the PR and that sessions are going through Swig now instead of a standalone Anchor program. Would an Anchor version still be useful as an alternative, or should I just close this out? Happy to rebase and fix the conflicts if it would, otherwise no worries. |
|
hey @alexanderattar, thanks for opening this PR! Note: the final program won't be hosted in this repo, it'll be a dedicated repo hosted on solana-program - supporting MPP and some other protocols we have in our radar. |
…dation#20 Port the Ruby x402 exact adapter from solana-foundation/x402-sdk PR solana-foundation#20 (tip 45e618f, Codex Round 4, 0 real P1, Confidence 4/5) into ruby/lib/x402/, following Ludo's rust/crates/x402/ pattern. Behavioral parity with rust/crates/x402 spine: - Program IDs, mints, CAIP-2 network IDs match types.rs verbatim - MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000, MAX_MEMO_BYTES = 256 - Lighthouse passthrough by program-ID only (no discriminator allowlist) - Compute-unit limit unbounded (parity-tracked) - Sign-then-verify ordering, resource binding, fee-payer attack guard - strict_decode64 for headers, short_vec UTF-8 fix, memo byte comparison - Namespace: X402SDK::Interop -> X402::Interop (top-level rename only) Tests: 42 server runs / 135 assertions; 26 client runs / 54 assertions; 0 failures across both suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dation#20 Port the Ruby x402 exact adapter from solana-foundation/x402-sdk PR solana-foundation#20 (tip 45e618f, Codex Round 4, 0 real P1, Confidence 4/5) into ruby/lib/x402/, following Ludo's rust/crates/x402/ pattern. Behavioral parity with rust/crates/x402 spine: - Program IDs, mints, CAIP-2 network IDs match types.rs verbatim - MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000, MAX_MEMO_BYTES = 256 - Lighthouse passthrough by program-ID only (no discriminator allowlist) - Compute-unit limit unbounded (parity-tracked) - Sign-then-verify ordering, resource binding, fee-payer attack guard - strict_decode64 for headers, short_vec UTF-8 fix, memo byte comparison - Namespace: X402SDK::Interop -> X402::Interop (top-level rename only) Tests: 42 server runs / 135 assertions; 26 client runs / 54 assertions; 0 failures across both suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dation#20 Port the Ruby x402 exact adapter from solana-foundation/x402-sdk PR solana-foundation#20 (tip 45e618f, Codex Round 4, 0 real P1, Confidence 4/5) into ruby/lib/x402/, following Ludo's rust/crates/x402/ pattern. Behavioral parity with rust/crates/x402 spine: - Program IDs, mints, CAIP-2 network IDs match types.rs verbatim - MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000, MAX_MEMO_BYTES = 256 - Lighthouse passthrough by program-ID only (no discriminator allowlist) - Compute-unit limit unbounded (parity-tracked) - Sign-then-verify ordering, resource binding, fee-payer attack guard - strict_decode64 for headers, short_vec UTF-8 fix, memo byte comparison - Namespace: X402SDK::Interop -> X402::Interop (top-level rename only) Tests: 42 server runs / 135 assertions; 26 client runs / 54 assertions; 0 failures across both suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dation#20 Port the Ruby x402 exact adapter from solana-foundation/x402-sdk PR solana-foundation#20 (tip 45e618f, Codex Round 4, 0 real P1, Confidence 4/5) into ruby/lib/x402/, following Ludo's rust/crates/x402/ pattern. Behavioral parity with rust/crates/x402 spine: - Program IDs, mints, CAIP-2 network IDs match types.rs verbatim - MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS = 5_000_000, MAX_MEMO_BYTES = 256 - Lighthouse passthrough by program-ID only (no discriminator allowlist) - Compute-unit limit unbounded (parity-tracked) - Sign-then-verify ordering, resource binding, fee-payer attack guard - strict_decode64 for headers, short_vec UTF-8 fix, memo byte comparison - Namespace: X402SDK::Interop -> X402::Interop (top-level rename only) Tests: 42 server runs / 135 assertions; 26 client runs / 54 assertions; 0 failures across both suites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
mpp-channelAnchor escrow program for on-chain payment channel settlementAnchor program
PaymentChannelaccount binds payer, payee, token mint, authorized signer, deposit, settled amount, grace period, forced-close timestamp, finalization status, and salt.PDA seeds:
["mpp-channel", payer, payee, token, salt_le, authorized_signer]Instructions:
opensettleclosetop_uprequest_closewithdrawThe Ed25519 precompile is not CPI-callable. The program validates signatures by reading the instructions sysvar at the index passed in
settle/closeargs, then parsing the raw JCS JSON bytes to extractchannelIdandcumulativeAmount. This is the same byte sequence signed for the HTTP credential, so no format translation is needed.Program ID:
21fLdahqKtVAt4V2JLwVrRb7tuqPADjjPVCU9bK3MFPQSession method changes
Aligned to PR #201:
channelId,cumulativeAmount,expiresAt)update→voucher,topup→topUpacceptedCumulative/spentAmountcumulativeAmount <= acceptedCumulativesucceeds silently with no state changeNew files:
src/anchor/MppChannelClient.ts— instruction builders and PDA derivationsrc/anchor/TransactionHandler.ts— server-side on-chain transaction verificationsrc/utils/ed25519.ts— Ed25519 instruction serialization (layout: 2-byte header + 14-byte descriptor, DATA_START=16)src/session/BinaryVoucher.ts— compact 48-byte voucher representationTests
anchor-channel.test.ts: 5 localnet integration tests (open, settle with Ed25519, full lifecycle, requestClose/withdraw, topUp + forced-close cancellation). Startssolana-test-validatorwith the compiled.so.binary-voucher.test.ts: round-trip tests for the JCS parser and compact voucher formatsession.test.ts: 62 unit tests for server/client session logicvitest.config.anchor.ts: separate config for anchor tests (120s timeout, single worker)Run anchor tests:
anchor test(requires Anchor CLI and Rust toolchain)Run unit tests:
pnpm exec vitest run --config vitest.config.tsDevnet verification
All instructions exercised against the deployed program with real transactions.
Setup
Channel lifecycle (open → settle → settle → close)
Forced close (requestClose → grace period → withdraw)
Balances verified: payee received exactly 800,000 tokens, payer holds 9,200,000 (started 10M, deposited 1M, refunded 200k). Forced close recovered the full 500,000 deposit.