diff --git a/.github/workflows/check_payment_channels_codegen.yml b/.github/workflows/check_payment_channels_codegen.yml new file mode 100644 index 0000000000..6c571f9ea7 --- /dev/null +++ b/.github/workflows/check_payment_channels_codegen.yml @@ -0,0 +1,39 @@ +name: Check Payment Channels Codegen + +on: + pull_request: + paths: + - ".github/workflows/check_payment_channels_codegen.yml" + - "typescript/pnpm-lock.yaml" + - "typescript/packages/mechanisms/svm/idl/**" + - "scripts/payment-channels-codegen/**" + - "typescript/packages/mechanisms/svm/src/payment-channels/generated/**" + - "typescript/packages/mechanisms/svm/package.json" + workflow_dispatch: + +permissions: + contents: read + id-token: none + +jobs: + check-payment-channels-codegen: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.1.1 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "24" + cache: "pnpm" + cache-dependency-path: scripts/payment-channels-codegen/pnpm-lock.yaml + + - name: Install codegen dependencies + run: pnpm --dir scripts/payment-channels-codegen install --frozen-lockfile --ignore-scripts + + - name: Verify generated client + run: pnpm --dir scripts/payment-channels-codegen check diff --git a/.github/workflows/publish_npm_scoped_x402_all.yml b/.github/workflows/publish_npm_scoped_x402_all.yml index 6122729318..5b9f5c847e 100644 --- a/.github/workflows/publish_npm_scoped_x402_all.yml +++ b/.github/workflows/publish_npm_scoped_x402_all.yml @@ -38,6 +38,10 @@ jobs: working-directory: ./typescript run: pnpm turbo run build --filter='@x402/*' + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish all packages in release order working-directory: ./typescript run: | diff --git a/.github/workflows/publish_npm_scoped_x402_aptos.yml b/.github/workflows/publish_npm_scoped_x402_aptos.yml index 102baf6415..11d6faf61e 100644 --- a/.github/workflows/publish_npm_scoped_x402_aptos.yml +++ b/.github/workflows/publish_npm_scoped_x402_aptos.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/aptos run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/aptos package working-directory: ./typescript/packages/mechanisms/aptos run: | diff --git a/.github/workflows/publish_npm_scoped_x402_avm.yml b/.github/workflows/publish_npm_scoped_x402_avm.yml index 5a623711b9..52e87895ea 100644 --- a/.github/workflows/publish_npm_scoped_x402_avm.yml +++ b/.github/workflows/publish_npm_scoped_x402_avm.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/avm run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/avm package working-directory: ./typescript/packages/mechanisms/avm run: | diff --git a/.github/workflows/publish_npm_scoped_x402_axios.yml b/.github/workflows/publish_npm_scoped_x402_axios.yml index b29ac8d030..2c7b38f137 100644 --- a/.github/workflows/publish_npm_scoped_x402_axios.yml +++ b/.github/workflows/publish_npm_scoped_x402_axios.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/axios run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/axios package working-directory: ./typescript/packages/http/axios run: | diff --git a/.github/workflows/publish_npm_scoped_x402_cardano.yml b/.github/workflows/publish_npm_scoped_x402_cardano.yml index 40dc54df22..b33d98f0c0 100644 --- a/.github/workflows/publish_npm_scoped_x402_cardano.yml +++ b/.github/workflows/publish_npm_scoped_x402_cardano.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/cardano run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/cardano package working-directory: ./typescript/packages/mechanisms/cardano run: | diff --git a/.github/workflows/publish_npm_scoped_x402_casper.yml b/.github/workflows/publish_npm_scoped_x402_casper.yml index 8024e538ab..ccfc68f91a 100644 --- a/.github/workflows/publish_npm_scoped_x402_casper.yml +++ b/.github/workflows/publish_npm_scoped_x402_casper.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/casper run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/casper package working-directory: ./typescript/packages/mechanisms/casper run: | diff --git a/.github/workflows/publish_npm_scoped_x402_concordium.yml b/.github/workflows/publish_npm_scoped_x402_concordium.yml index 95c8512ce1..2e12695bca 100644 --- a/.github/workflows/publish_npm_scoped_x402_concordium.yml +++ b/.github/workflows/publish_npm_scoped_x402_concordium.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/concordium run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/concordium package working-directory: ./typescript/packages/mechanisms/concordium run: | diff --git a/.github/workflows/publish_npm_scoped_x402_core.yml b/.github/workflows/publish_npm_scoped_x402_core.yml index 6942c5b55f..679ce6a42c 100644 --- a/.github/workflows/publish_npm_scoped_x402_core.yml +++ b/.github/workflows/publish_npm_scoped_x402_core.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/core package working-directory: ./typescript/packages/core run: | diff --git a/.github/workflows/publish_npm_scoped_x402_evm.yml b/.github/workflows/publish_npm_scoped_x402_evm.yml index 6b765b619f..a875ed864d 100644 --- a/.github/workflows/publish_npm_scoped_x402_evm.yml +++ b/.github/workflows/publish_npm_scoped_x402_evm.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/evm run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/evm package working-directory: ./typescript/packages/mechanisms/evm run: | diff --git a/.github/workflows/publish_npm_scoped_x402_express.yml b/.github/workflows/publish_npm_scoped_x402_express.yml index 6fc53508f4..b41fb8ae5f 100644 --- a/.github/workflows/publish_npm_scoped_x402_express.yml +++ b/.github/workflows/publish_npm_scoped_x402_express.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/express run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/express package working-directory: ./typescript/packages/http/express run: | diff --git a/.github/workflows/publish_npm_scoped_x402_extensions.yml b/.github/workflows/publish_npm_scoped_x402_extensions.yml index b9e594f5a5..fe289dd663 100644 --- a/.github/workflows/publish_npm_scoped_x402_extensions.yml +++ b/.github/workflows/publish_npm_scoped_x402_extensions.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/extensions package working-directory: ./typescript/packages/extensions run: | diff --git a/.github/workflows/publish_npm_scoped_x402_fastify.yml b/.github/workflows/publish_npm_scoped_x402_fastify.yml index 81b00f7e03..050df7c57b 100644 --- a/.github/workflows/publish_npm_scoped_x402_fastify.yml +++ b/.github/workflows/publish_npm_scoped_x402_fastify.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/fastify run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/fastify package working-directory: ./typescript/packages/http/fastify run: | diff --git a/.github/workflows/publish_npm_scoped_x402_fetch.yml b/.github/workflows/publish_npm_scoped_x402_fetch.yml index b3161a693e..129bbe2ef5 100644 --- a/.github/workflows/publish_npm_scoped_x402_fetch.yml +++ b/.github/workflows/publish_npm_scoped_x402_fetch.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/fetch run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/fetch package working-directory: ./typescript/packages/http/fetch run: | diff --git a/.github/workflows/publish_npm_scoped_x402_hedera.yml b/.github/workflows/publish_npm_scoped_x402_hedera.yml index 68abb8379d..71950626e3 100644 --- a/.github/workflows/publish_npm_scoped_x402_hedera.yml +++ b/.github/workflows/publish_npm_scoped_x402_hedera.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/hedera run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/hedera package working-directory: ./typescript/packages/mechanisms/hedera run: | diff --git a/.github/workflows/publish_npm_scoped_x402_hono.yml b/.github/workflows/publish_npm_scoped_x402_hono.yml index f1ad57ea04..5267b548bc 100644 --- a/.github/workflows/publish_npm_scoped_x402_hono.yml +++ b/.github/workflows/publish_npm_scoped_x402_hono.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/hono run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/hono package working-directory: ./typescript/packages/http/hono run: | diff --git a/.github/workflows/publish_npm_scoped_x402_keeta.yml b/.github/workflows/publish_npm_scoped_x402_keeta.yml index ca2a6b2bba..74ac31e7a4 100644 --- a/.github/workflows/publish_npm_scoped_x402_keeta.yml +++ b/.github/workflows/publish_npm_scoped_x402_keeta.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/keeta run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/keeta package working-directory: ./typescript/packages/mechanisms/keeta run: | diff --git a/.github/workflows/publish_npm_scoped_x402_mcp.yml b/.github/workflows/publish_npm_scoped_x402_mcp.yml index 0a088e11ad..655c562a8a 100644 --- a/.github/workflows/publish_npm_scoped_x402_mcp.yml +++ b/.github/workflows/publish_npm_scoped_x402_mcp.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/mcp run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/mcp package working-directory: ./typescript/packages/mcp run: | diff --git a/.github/workflows/publish_npm_scoped_x402_near.yml b/.github/workflows/publish_npm_scoped_x402_near.yml index 0cc8f55432..67564755c9 100644 --- a/.github/workflows/publish_npm_scoped_x402_near.yml +++ b/.github/workflows/publish_npm_scoped_x402_near.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/near run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/near package working-directory: ./typescript/packages/mechanisms/near run: | diff --git a/.github/workflows/publish_npm_scoped_x402_next.yml b/.github/workflows/publish_npm_scoped_x402_next.yml index afb375697d..b24dd4a984 100644 --- a/.github/workflows/publish_npm_scoped_x402_next.yml +++ b/.github/workflows/publish_npm_scoped_x402_next.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/next run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/next package working-directory: ./typescript/packages/http/next run: | diff --git a/.github/workflows/publish_npm_scoped_x402_paywall.yml b/.github/workflows/publish_npm_scoped_x402_paywall.yml index 698f514ca9..9ab0069335 100644 --- a/.github/workflows/publish_npm_scoped_x402_paywall.yml +++ b/.github/workflows/publish_npm_scoped_x402_paywall.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/evm --filter=@x402/svm --filter=@x402/avm --filter=@x402/paywall run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/paywall package working-directory: ./typescript/packages/http/paywall run: | diff --git a/.github/workflows/publish_npm_scoped_x402_stellar.yml b/.github/workflows/publish_npm_scoped_x402_stellar.yml index 445a3efc4a..0ea34a0306 100644 --- a/.github/workflows/publish_npm_scoped_x402_stellar.yml +++ b/.github/workflows/publish_npm_scoped_x402_stellar.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/stellar run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/stellar package working-directory: ./typescript/packages/mechanisms/stellar run: | diff --git a/.github/workflows/publish_npm_scoped_x402_svm.yml b/.github/workflows/publish_npm_scoped_x402_svm.yml index 0b34e0aad2..6c75237797 100644 --- a/.github/workflows/publish_npm_scoped_x402_svm.yml +++ b/.github/workflows/publish_npm_scoped_x402_svm.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/svm run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/svm package working-directory: ./typescript/packages/mechanisms/svm run: | diff --git a/.github/workflows/publish_npm_scoped_x402_tvm.yml b/.github/workflows/publish_npm_scoped_x402_tvm.yml index ead7fb8386..7a44241d6b 100644 --- a/.github/workflows/publish_npm_scoped_x402_tvm.yml +++ b/.github/workflows/publish_npm_scoped_x402_tvm.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/extensions --filter=@x402/tvm run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/tvm package working-directory: ./typescript/packages/mechanisms/tvm run: | diff --git a/.github/workflows/publish_npm_scoped_x402_xrpl.yml b/.github/workflows/publish_npm_scoped_x402_xrpl.yml index 56a6e381fd..30500fc583 100644 --- a/.github/workflows/publish_npm_scoped_x402_xrpl.yml +++ b/.github/workflows/publish_npm_scoped_x402_xrpl.yml @@ -36,6 +36,10 @@ jobs: pnpm install --frozen-lockfile --ignore-scripts pnpm -r --filter=@x402/core --filter=@x402/xrpl run build + - name: Verify package exports + working-directory: ./typescript + run: pnpm verify:exports + - name: Publish @x402/xrpl package working-directory: ./typescript/packages/mechanisms/xrpl run: | diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e9493e2211..d6e7dd82be 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -30,4 +30,5 @@ jobs: working-directory: ./typescript run: | pnpm install --frozen-lockfile --ignore-scripts - pnpm run test + pnpm exec turbo run test --concurrency=50% + pnpm verify:exports diff --git a/contracts/evm/README.md b/contracts/evm/README.md index b1543e2262..e6b47496c6 100644 --- a/contracts/evm/README.md +++ b/contracts/evm/README.md @@ -43,6 +43,8 @@ Both contracts: |-------|-------|------| | Base Mainnet | [Deployed](https://basescan.org/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | [Deployed](https://basescan.org/address/0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002) | | Base Sepolia | [Deployed](https://sepolia.basescan.org/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | [Legacy\*](https://sepolia.basescan.org/address/0x402039b3d6E6BEC5A02c2C9fd937ac17A6940002) | +| Arc Mainnet | — | [Deployed](https://explorer.arc.io/address/0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002) | +| Arc Testnet | [Deployed](https://explorer.testnet.arc.io/address/0x402085c248EeA27D92E8b30b2C58ed07f9E20001) | [Deployed](https://explorer.testnet.arc.io/address/0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002) | **Batch settlement deployments** diff --git a/docs/core-concepts/network-and-token-support.mdx b/docs/core-concepts/network-and-token-support.mdx index f4822c9372..8641bf129b 100644 --- a/docs/core-concepts/network-and-token-support.mdx +++ b/docs/core-concepts/network-and-token-support.mdx @@ -66,9 +66,11 @@ Both methods provide gasless transfers (facilitator sponsors gas), signature-bas **Permit2 approval**: Requires a one-time onchain approval. x402 supports two gas sponsoring extensions that make this step gasless — see [EIP-2612 Gas Sponsoring](/extensions/eip2612-gas-sponsoring) and [ERC-20 Approval Gas Sponsoring](/extensions/erc20-approval-gas-sponsoring). -### EVM: Batch settlement (`batch-settlement`) +### Batch settlement (`batch-settlement`) -For high-frequency or repeated micropayment flows where settling every request onchain is too costly, x402 supports the **`batch-settlement`** scheme on EVM: the buyer deposits once into an escrow contract, signs off-chain vouchers per request, and sellers redeem in batches onchain. Per-request pricing still uses an **up-to** ceiling (`PaymentRequirements.amount`); actual charges can vary per request within that limit—see **[Batch settlement](/schemes/batch-settlement)**. +For high-frequency or repeated micropayment flows where settling every request onchain is too costly, x402 supports the **`batch-settlement`** scheme on EVM and Solana (SVM): the buyer deposits once into an escrow channel, signs off-chain cumulative vouchers per request, and sellers redeem in batches onchain. Per-request pricing still uses an **up-to** ceiling (`PaymentRequirements.amount`); actual charges can vary per request within that limit—see **[Batch settlement](/schemes/batch-settlement)**. + +On EVM, the escrow is an onchain contract using EIP-3009 or Permit2. On SVM, the escrow uses the [payment-channels program](https://github.com/solana-foundation/payment-channels), the same program used by `upto`. The TypeScript SDK supports both networks; Go and Python currently support EVM only—see [SDK Features](/sdk-features). ### Specifying Payment Amounts diff --git a/docs/schemes/batch-settlement.mdx b/docs/schemes/batch-settlement.mdx index 5e07e1f41f..845755a676 100644 --- a/docs/schemes/batch-settlement.mdx +++ b/docs/schemes/batch-settlement.mdx @@ -1,6 +1,6 @@ --- title: "Batch Settlement" -description: "High-throughput EVM payments with escrow, off-chain vouchers, and batched onchain redemption." +description: "High-throughput payments with escrow, off-chain vouchers, and batched onchain redemption. Available on EVM and Solana (SVM)." --- The `batch-settlement` scheme is for high-throughput payments where many requests are authorized individually and redeemed later in batches. The EVM implementation uses stateless unidirectional payment channels: a buyer deposits funds into onchain escrow once, then signs off-chain cumulative vouchers for each request. The seller verifies vouchers quickly and redeems them onchain later in batches. @@ -561,6 +561,159 @@ Set `withdrawDelay` greater than your claim cadence plus an operational safety m * [Python server](https://github.com/x402-foundation/x402/tree/main/examples/python/servers/batch-settlement) * [Python facilitator](https://github.com/x402-foundation/x402/tree/main/examples/python/facilitator/batch-settlement) +### SVM Implementation + +`batch-settlement` on Solana uses the same [payment-channels program](https://github.com/solana-foundation/payment-channels) as `upto`. Unlike `upto`, which closes after one metered charge, `batch-settlement` keeps the channel open across many requests and advances a cumulative watermark. The client deposits once, then signs off-chain cumulative vouchers per request; the server verifies offchain, serves immediately, and redeems accumulated vouchers later through the facilitator. + +Two voucher modes are supported: + +| Mode | Who signs vouchers | Per-request payload | Typical use | +|------|--------------------|---------------------|-------------| +| **Client-signed** (default) | Client (`payerAuthorizer`) | Cumulative Ed25519 voucher | Fixed price per request; client retains full voucher authority | +| **Server-signed** | Resource `operator` | Expiring payer proof + request id; operator signs the metered cumulative charge after serving | Usage-based billing without a client signature on every request | + +In server mode the channel's onchain `authorized_signer` is the operator, who could claim the full unspent deposit. Clients enter that mode only for operator keys they have explicitly trusted out of band. + +#### Server Setup (SVM) + +Every channel is bound at open to a `receiverAuthorizer` key that signs cooperative close authorizations. Pass `receiverAuthorizer` to self-manage closes (recommended), or omit it to delegate to the facilitator's advertised key. + +```typescript +import { BatchSvmScheme, MemoryChannelStore } from "@x402/svm/batch-settlement/server"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +const receiverAuthorizer = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY!), +); + +const batchSvmScheme = new BatchSvmScheme({ + receiverAuthorizer, + withdrawDelay: 86_400, + store: new MemoryChannelStore(), +}); + +const resourceServer = new x402ResourceServer(facilitatorClient) + .register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", batchSvmScheme); + +app.use(paymentMiddleware({ + "GET /api/data": { + accepts: { + scheme: "batch-settlement", + price: "$0.01", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: "YourSolanaAddress", + }, + description: "Data API", + }, +}, resourceServer)); +``` + +For server-signed (metered) routes, configure an `operator` signer and use `setSettlementOverrides` to charge actual usage: + +```typescript +const operator = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_OPERATOR_PRIVATE_KEY!), +); + +const batchSvmScheme = new BatchSvmScheme({ + receiverAuthorizer, + operator, + store: new MemoryChannelStore(), +}); + +// In your handler: +app.get("/api/generate", (req, res) => { + setSettlementOverrides(res, { amount: "50%" }); // or raw atomic units / dollar price + res.json({ result: "..." }); +}); +``` + +#### Redemption Worker (SVM) + +Start `BatchChannelManager` outside the request path to claim batches and distribute payouts: + +```typescript +const manager = batchSvmScheme.createChannelManager(facilitatorClient, requirements, { + rpcUrl: process.env.SVM_RPC_URL, + onClaim: r => console.log(`Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), + onSettle: r => console.log(`Distributed to payTo (tx: ${r.transaction})`), + onSeal: r => console.log(`Sealed channel ${r.channel} (tx: ${r.transaction})`), + onError: e => console.error(e), +}); +manager.start(60); // seconds between passes +``` + +Claim promptly: if the facilitator or payer forces a close before your latest voucher is claimed, unclaimed value returns to the payer. Set `withdrawDelay` comfortably above your claim cadence. + +#### Client Setup (SVM) + +Register `BatchSvmScheme` for `solana:*`. The SDK opens or tops up the channel on first use and attaches the correct payload per mode: + +```typescript +import { x402Client } from "@x402/core/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { BatchSvmScheme } from "@x402/svm/batch-settlement/client"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +const signer = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_PRIVATE_KEY!), +); + +const client = new x402Client(); +client.register("solana:*", new ExactSvmScheme(signer)); +client.register("solana:*", new BatchSvmScheme(signer, { + depositPolicy: { depositMultiplier: 5 }, + rpcUrl: process.env.SVM_RPC_URL, +})); +``` + +To opt into server-signed channels for a trusted operator: + +```typescript +const batch = new BatchSvmScheme(signer, { + serverSignedChannelsPolicy: { + allowedOperators: ["OperatorBase58Pubkey"], + maxDeposit: "$1", // USD cap for default assets + }, +}); +client.register("solana:*", batch); +``` + +#### Facilitator Setup (SVM) + +```typescript +import { toFacilitatorSvmSigner } from "@x402/svm"; +import { + BatchSvmScheme, + InMemoryBatchChannelStorage, + InMemoryBatchReceiverAuthorizerStore, +} from "@x402/svm/batch-settlement/facilitator"; + +const svmSigner = toFacilitatorSvmSigner( + keypair, + process.env.SVM_RPC_URL ? { defaultRpcUrl: process.env.SVM_RPC_URL } : undefined, +); + +const scheme = new BatchSvmScheme(svmSigner, { + channelStorage: new InMemoryBatchChannelStorage(), + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), +}); + +facilitator.register("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", scheme); + +// Reclaim PDA rent from abandoned channels +const rentCleanup = scheme.createRentCleanupManager("solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"); +rentCleanup.start({ intervalSecs: 60, abandonGraceSecs: 120 }); +``` + +#### SVM Examples + +* [TypeScript batch-settlement server (EVM + SVM)](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/batch-settlement) +* [TypeScript batch-settlement client](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients/batch-settlement) +* [TypeScript batch-settlement facilitator](https://github.com/x402-foundation/x402/tree/main/examples/typescript/facilitator/batch-settlement) + ### Specs * [`batch-settlement` spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/batch-settlement/scheme_batch_settlement.md) diff --git a/docs/schemes/exact.mdx b/docs/schemes/exact.mdx index 154d898cd7..ac2ad73b72 100644 --- a/docs/schemes/exact.mdx +++ b/docs/schemes/exact.mdx @@ -544,7 +544,7 @@ For testnet funds, get test ADA from the [Cardano testnets faucet](https://docs. ### Network Implementations -The `exact` scheme has network specifications for EVM, SVM, AVM, Stellar, Aptos, Casper, Hedera, TON, Cardano, Keeta, Sui, Concordium, NEAR, and XRPL. +The `exact` scheme has network specifications for EVM, SVM, AVM, Stellar, Aptos, Casper, Hedera, TON, Cardano, Keeta, Sui, Concordium, NEAR, XRPL, and Bitcoin Lightning (`lnbtc`). ### SVM Smart Wallet Support @@ -662,6 +662,7 @@ Permit2 may require a one-time approval. The gas sponsoring extensions can let t * [`exact` Sui spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_sui.md) * [`exact` NEAR spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_near.md) * [`exact` XRPL spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_xrpl.md) +* [`exact` Bitcoin Lightning spec](https://github.com/x402-foundation/x402/blob/main/specs/schemes/exact/scheme_exact_lnbtc.md) ### See Also diff --git a/docs/schemes/overview.mdx b/docs/schemes/overview.mdx index 28f7a639f7..d4c3914184 100644 --- a/docs/schemes/overview.mdx +++ b/docs/schemes/overview.mdx @@ -3,7 +3,7 @@ title: "Payment Schemes" description: "Choose between exact, upto, and batch-settlement payment schemes in x402." --- -Payment schemes define the payment semantics for a resource: `exact` is for fixed-price requests where the buyer authorizes the advertised amount, `upto` is for single-request usage-based billing where the buyer authorizes a maximum and the seller charges actual usage, and `batch-settlement` is for high-volume or repeated micropayments where per-request authorizations are accumulated against a reusable channel. Network implementations define how those semantics are encoded for EVM, SVM, TVM (TON), AVM, Stellar, Aptos, Hedera, Keeta, Concordium, NEAR, and XRPL; see [SDK Features](/sdk-features) for current SDK support. +Payment schemes define the payment semantics for a resource: `exact` is for fixed-price requests where the buyer authorizes the advertised amount, `upto` is for single-request usage-based billing where the buyer authorizes a maximum and the seller charges actual usage, and `batch-settlement` is for high-volume or repeated micropayments where per-request authorizations are accumulated against a reusable channel. Network implementations define how those semantics are encoded for EVM, SVM, TVM (TON), AVM, Stellar, Aptos, Hedera, Keeta, Concordium, NEAR, XRPL, and Bitcoin Lightning (`lnbtc`); see [SDK Features](/sdk-features) for current SDK support. ### Schemes in a 402 Response diff --git a/docs/sdk-features.md b/docs/sdk-features.md index bf58253843..27d967ec99 100644 --- a/docs/sdk-features.md +++ b/docs/sdk-features.md @@ -65,6 +65,7 @@ This page tracks which features are implemented in each SDK (TypeScript, Go, Pyt | upto | svm | - | ✅ | ✅ | ❌ | | batch-settlement | evm | `eip3009` | ✅ | ✅ | ✅ | | batch-settlement | evm | `permit2` | ✅ | ✅ | ✅ | +| batch-settlement | svm | - | ✅ | ❌ | ❌ | | auth-capture | evm | `eip3009` | ✅ (client) | ✅ (client) | ❌ | | auth-capture | evm | `permit2` | ✅ (client) | ✅ (client) | ❌ | diff --git a/e2e/.env-local b/e2e/.env-local index e5e4421667..21bf175f78 100644 --- a/e2e/.env-local +++ b/e2e/.env-local @@ -1,11 +1,14 @@ # E2E wallet/payee placeholders — copy to e2e/.env and fill in. # Keys mirror required credentials in config/mechanisms_.json (see e2e/README.md § Add a network). -# Optional route overrides (SERVER_*_AMOUNT/ASSET, batch-settlement, TVM provider, RPC) are not listed here. +# Optional route overrides (SERVER_*_AMOUNT/ASSET, TVM provider, RPC) and batch-settlement toggles are commented below where applicable. # Server payment addresses SERVER_EVM_ADDRESS= SERVER_SVM_ADDRESS= +# Optional batch-settlement / upto (see e2e/README.md) +SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= +SERVER_SVM_OPERATOR_PRIVATE_KEY= SERVER_AVM_ADDRESS= SERVER_APTOS_ADDRESS= SERVER_CCD_ADDRESS= @@ -19,7 +22,12 @@ SERVER_CARDANO_ADDRESS= # Client wallets CLIENT_EVM_PRIVATE_KEY= +CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY= +# EVM_BATCH_SETTLEMENT_RECOVERY=true CLIENT_SVM_PRIVATE_KEY= +CLIENT_SVM_SERVER_SIGNED_OPERATORS= +CLIENT_SVM_SERVER_SIGNED_MAX_DEPOSIT= +# SVM_BATCH_SETTLEMENT_RECOVERY=true CLIENT_AVM_PRIVATE_KEY= CLIENT_APTOS_PRIVATE_KEY= CLIENT_CASPER_PRIVATE_KEY= @@ -39,6 +47,8 @@ CLIENT_CARDANO_MNEMONIC= # Facilitator wallets (XRPL facilitator is keyless — no FACILITATOR_XRPL_*) FACILITATOR_EVM_PRIVATE_KEY= FACILITATOR_SVM_PRIVATE_KEY= +FACILITATOR_SVM_BATCH_BINDING_STORE= +SVM_ARCHIVE_RPC_URL= FACILITATOR_AVM_PRIVATE_KEY= FACILITATOR_APTOS_PRIVATE_KEY= FACILITATOR_CASPER_PRIVATE_KEY= diff --git a/e2e/README.md b/e2e/README.md index 20548a00a2..044bc95b6b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -301,10 +301,19 @@ pnpm test --testnet --min --families=evm --sdk=ts --paymentflow=upfront --assetT Optional environment variables (batch-settlement scheme): ```bash +# EVM SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=0x... # optional: self-managed receiver authorizer (omit to delegate to facilitator /supported) -SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=... # server hot key that signs upto settlement vouchers (no SOL required) CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY=0x... # EOA the client uses to sign vouchers EVM_BATCH_SETTLEMENT_RECOVERY=true # test client state-loss recovery scenario (default: true) + +# SVM +SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY=... # required for /upto/svm and /batch-settlement/svm; signs upto vouchers and the batch receiver authorizer (no SOL required) +SERVER_SVM_OPERATOR_PRIVATE_KEY=... # required for /batch-settlement-server-signed/svm; operator that meters and signs vouchers +CLIENT_SVM_SERVER_SIGNED_OPERATORS=... # optional: base58 operator pubkeys trusted by the client (defaults to pubkey derived from SERVER_SVM_OPERATOR_PRIVATE_KEY on server-signed routes) +CLIENT_SVM_SERVER_SIGNED_MAX_DEPOSIT=$1 # optional USD escrow cap per server-signed channel (default asset) +FACILITATOR_SVM_BATCH_BINDING_STORE=memory # optional: `memory` (default) uses in-memory receiver-authorizer store; `none` relies on RPC history reads +SVM_ARCHIVE_RPC_URL=https://... # optional: full-history RPC for receiver-binding reconstruction (archive node) +SVM_BATCH_SETTLEMENT_RECOVERY=true # test client state-loss recovery scenario (default: true) ``` Optional environment variables for XRPL issued-currency tests are generated by diff --git a/e2e/clients/typescript/client.ts b/e2e/clients/typescript/client.ts index 1a698c53cb..f69947bb99 100644 --- a/e2e/clients/typescript/client.ts +++ b/e2e/clients/typescript/client.ts @@ -9,6 +9,7 @@ import { import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { ExactEvmSchemeV1 } from "@x402/evm/v1"; import { toClientEvmSigner } from "@x402/evm"; +import { BatchSvmScheme as BatchSettlementSvmScheme } from "@x402/svm/batch-settlement/client"; import { ExactSvmScheme } from "@x402/svm/exact/client"; import { UptoSvmScheme } from "@x402/svm/upto/client"; import { ExactSvmSchemeV1 } from "@x402/svm/v1"; @@ -80,10 +81,24 @@ export type E2EClientContext = { * setup instead of duplicating it. */ schemes: SchemeRegistration[]; - batchSettlementScheme: BatchSettlementEvmScheme | undefined; + batchSettlementScheme: BatchSettlementScheme | undefined; batchSettlementPhase: BatchSettlementPhase | undefined; }; +/** Either family's batch-settlement client: both expose `refund(url)`. */ +export type BatchSettlementScheme = BatchSettlementEvmScheme | BatchSettlementSvmScheme; + +/** + * The harness derives one 32-byte hex salt per scenario. The SVM channel PDA + * takes a u64 salt, so fold the hex down to 64 bits. + * + * @param channelSalt - 0x-prefixed hex salt from the harness + * @returns Decimal u64 salt for the SVM client + */ +function svmChannelSalt(channelSalt: string): string { + return (BigInt(channelSalt) % (1n << 64n)).toString(); +} + /** * Builds the shared x402 client with all e2e scheme registrations. */ @@ -93,7 +108,11 @@ export async function createE2EClient(): Promise { const url = `${baseURL}${endpointPath}`; const schemes: SchemeRegistration[] = []; - let batchSettlementScheme: BatchSettlementEvmScheme | undefined; + let batchSettlementScheme: BatchSettlementScheme | undefined; + let svmServerSignedOperators: string[] = []; + // One process runs one endpoint, so the route path picks which family's + // batch-settlement scheme drives the phases and the refund. + const batchFamily: "svm" | "evm" = endpointPath.includes("/svm") ? "svm" : "evm"; if (process.env.CLIENT_EVM_PRIVATE_KEY) { const evmAccount = privateKeyToAccount(process.env.CLIENT_EVM_PRIVATE_KEY as `0x${string}`); @@ -119,7 +138,8 @@ export async function createE2EClient(): Promise { // concurrent e2e runs don't collide on the same on-chain channel id. An optional // voucher signer (CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY) exercises // the alt-EOA voucher branch while deposits keep using the main client signer. - const channelSalt = process.env.EVM_BATCH_SETTLEMENT_CHANNEL as `0x${string}` | undefined; + const channelSalt = (process.env.BATCH_SETTLEMENT_CHANNEL ?? + process.env.EVM_BATCH_SETTLEMENT_CHANNEL) as `0x${string}` | undefined; const voucherSignerKey = process.env.CLIENT_EVM_BATCH_SETTLEMENT_VOUCHER_SIGNER_PRIVATE_KEY as | `0x${string}` | undefined; @@ -130,7 +150,8 @@ export async function createE2EClient(): Promise { channelSalt || voucherSigner ? { ...(channelSalt ? { salt: channelSalt } : {}), ...(voucherSigner ? { voucherSigner } : {}) } : undefined; - batchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + const evmBatchSettlementScheme = new BatchSettlementEvmScheme(evmSigner, batchSettlementOptions); + if (batchFamily === "evm") batchSettlementScheme = evmBatchSettlementScheme; schemes.push( { network: networkCaip2Pattern("evm"), client: new ExactEvmScheme(evmSigner, evmSchemeOptions) }, @@ -138,7 +159,7 @@ export async function createE2EClient(): Promise { network: networkCaip2Pattern("evm"), client: new UptoEvmClientScheme(evmSigner, uptoSchemeOptions), }, - { network: networkCaip2Pattern("evm"), client: batchSettlementScheme }, + { network: networkCaip2Pattern("evm"), client: evmBatchSettlementScheme }, { network: "base-sepolia", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, { network: "base", client: new ExactEvmSchemeV1(evmSigner), x402Version: 1 }, ); @@ -150,6 +171,31 @@ export async function createE2EClient(): Promise { ); const svmSchemeOptions = process.env.SVM_RPC_URL ? { rpcUrl: process.env.SVM_RPC_URL } : undefined; + // Batch-settlement derives the channel PDA from a per-scenario salt so + // concurrent e2e runs do not collide on one onchain channel. Discovery + // stays on: the recovery phase runs in a fresh process and must find the + // channel the initial phase opened. + const svmSaltHex = process.env.BATCH_SETTLEMENT_CHANNEL ?? process.env.EVM_BATCH_SETTLEMENT_CHANNEL; + svmServerSignedOperators = + process.env.CLIENT_SVM_SERVER_SIGNED_OPERATORS + ?.split(",") + .map(part => part.trim()) + .filter(Boolean) ?? []; + const serverSignedMaxDeposit = process.env.CLIENT_SVM_SERVER_SIGNED_MAX_DEPOSIT?.trim(); + const svmBatchSettlementScheme = new BatchSettlementSvmScheme(svmSigner, { + ...svmSchemeOptions, + ...(svmSaltHex ? { salt: svmChannelSalt(svmSaltHex) } : {}), + ...(svmServerSignedOperators.length > 0 + ? { + serverSignedChannelsPolicy: { + allowedOperators: svmServerSignedOperators, + ...(serverSignedMaxDeposit ? { maxDeposit: serverSignedMaxDeposit } : {}), + }, + } + : {}), + }); + if (batchFamily === "svm") batchSettlementScheme = svmBatchSettlementScheme; + schemes.push( { network: networkCaip2Pattern("svm"), @@ -159,6 +205,7 @@ export async function createE2EClient(): Promise { network: networkCaip2Pattern("svm"), client: new UptoSvmScheme(svmSigner, svmSchemeOptions), }, + { network: networkCaip2Pattern("svm"), client: svmBatchSettlementScheme }, { network: "solana-devnet", client: new ExactSvmSchemeV1(svmSigner, svmSchemeOptions), @@ -340,7 +387,12 @@ export async function createE2EClient(): Promise { spendControls: false, }); - const batchSettlementPhase = process.env.EVM_BATCH_SETTLEMENT_PHASE as BatchSettlementPhase | undefined; + if (batchSettlementScheme instanceof BatchSettlementSvmScheme && svmServerSignedOperators.length > 0) { + client.registerPolicy(batchSettlementScheme.paymentPolicy); + } + + const batchSettlementPhase = (process.env.BATCH_SETTLEMENT_PHASE ?? + process.env.EVM_BATCH_SETTLEMENT_PHASE) as BatchSettlementPhase | undefined; return { url, client, schemes, batchSettlementScheme, batchSettlementPhase }; } @@ -368,7 +420,7 @@ function aggregateBatchResult( export type ClientScenarioDeps = { url: string; batchSettlementPhase: BatchSettlementPhase | undefined; - batchSettlementScheme: BatchSettlementEvmScheme | undefined; + batchSettlementScheme: BatchSettlementScheme | undefined; issueRequest: () => Promise; /** * Overrides how the cooperative refund request is sent. Defaults to @@ -419,8 +471,8 @@ export async function runClientScenario(deps: ClientScenarioDeps): Promise const { url, batchSettlementPhase, batchSettlementScheme, issueRequest } = deps; if (batchSettlementPhase && !batchSettlementScheme) { throw new Error( - "EVM_BATCH_SETTLEMENT_PHASE is set but no CLIENT_EVM_PRIVATE_KEY was provided to build a " + - "batch-settlement scheme from.", + "BATCH_SETTLEMENT_PHASE is set but no CLIENT_EVM_PRIVATE_KEY / CLIENT_SVM_PRIVATE_KEY was " + + "provided to build the batch-settlement scheme for this endpoint from.", ); } const sendRefund = deps.refund ?? (() => batchSettlementScheme!.refund(url)); @@ -479,5 +531,5 @@ export async function runClientScenario(deps: ClientScenarioDeps): Promise process.exit(0); } - throw new Error(`Unknown EVM_BATCH_SETTLEMENT_PHASE: ${batchSettlementPhase}`); + throw new Error(`Unknown BATCH_SETTLEMENT_PHASE: ${batchSettlementPhase}`); } diff --git a/e2e/config/mechanisms_svm.json b/e2e/config/mechanisms_svm.json index be4e329a81..cc0dc2aefb 100644 --- a/e2e/config/mechanisms_svm.json +++ b/e2e/config/mechanisms_svm.json @@ -5,8 +5,32 @@ "required": false, "roles": ["server"] }, + "SERVER_SVM_OPERATOR_PRIVATE_KEY": { + "required": false, + "roles": ["server"] + }, "CLIENT_SVM_PRIVATE_KEY": { "required": true, "roles": ["client"] }, + "CLIENT_SVM_SERVER_SIGNED_OPERATORS": { + "required": false, + "roles": ["client"] + }, + "CLIENT_SVM_SERVER_SIGNED_MAX_DEPOSIT": { + "required": false, + "roles": ["client"] + }, "FACILITATOR_SVM_PRIVATE_KEY": { "required": true, "roles": ["facilitator"] }, + "FACILITATOR_SVM_BATCH_BINDING_STORE": { + "required": false, + "roles": ["facilitator"] + }, + "SVM_ARCHIVE_RPC_URL": { + "required": false, + "roles": ["facilitator"] + }, + "SVM_BATCH_SETTLEMENT_RECOVERY": { + "required": false, + "roles": ["client"] + }, "SWIG_ACCOUNT_ADDRESS": { "required": false, "roles": ["client"] }, "SWIG_ID_BASE58": { "required": false, "roles": ["client"] } }, @@ -42,6 +66,22 @@ "price": { "usd": "$0.002" }, "extensions": ["bazaar"], "settlementOverride": { "amount": "1000" } + }, + "/batch-settlement/svm": { + "scheme": "batch-settlement", + "requiresEnv": "SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY", + "sdks": ["typescript"], + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"], + "requirementsExtra": { "voucherSigner": "client" } + }, + "/batch-settlement-server-signed/svm": { + "scheme": "batch-settlement", + "requiresEnv": "SERVER_SVM_OPERATOR_PRIVATE_KEY", + "sdks": ["typescript"], + "price": { "usd": "$0.001" }, + "extensions": ["bazaar"], + "settlementOverride": { "amount": "50%" } } } } diff --git a/e2e/facilitators/typescript/index.ts b/e2e/facilitators/typescript/index.ts index ccdc039fe7..9932cff1bc 100644 --- a/e2e/facilitators/typescript/index.ts +++ b/e2e/facilitators/typescript/index.ts @@ -21,7 +21,10 @@ import { base58 } from "@scure/base"; import { createKeyPairSignerFromBytes } from "@solana/kit"; import { toFacilitatorAptosSigner } from "@x402/aptos"; import { ExactAptosScheme } from "@x402/aptos/exact/facilitator"; -import { FacilitatorCasperSigner, createFacilitatorCasperSigner } from "@x402/casper"; +import { + FacilitatorCasperSigner, + createFacilitatorCasperSigner, +} from "@x402/casper"; import { ExactCasperScheme } from "@x402/casper/exact/facilitator"; import { toFacilitatorAvmSigner } from "@x402/avm"; import { ExactAvmScheme } from "@x402/avm/exact/facilitator"; @@ -39,7 +42,11 @@ import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; import { ExactEvmSchemeV1 } from "@x402/evm/exact/v1/facilitator"; import { NETWORKS as EVM_V1_NETWORKS } from "@x402/evm/v1"; -import { BAZAAR, extractDiscoveryInfo, type DiscoveryResource } from "@x402/extensions/bazaar"; +import { + BAZAAR, + extractDiscoveryInfo, + type DiscoveryResource, +} from "@x402/extensions/bazaar"; import { EIP2612_GAS_SPONSORING, createErc20ApprovalGasSponsoringExtension, @@ -56,6 +63,12 @@ import { } from "@x402/hedera"; import { ExactHederaScheme } from "@x402/hedera/exact/facilitator"; import { toFacilitatorSvmSigner } from "@x402/svm"; +import { + BatchSvmScheme as BatchSettlementSvmScheme, + createReceiverBindingHistoryReader, + InMemoryBatchReceiverAuthorizerStore, + type BatchSvmFacilitatorConfig, +} from "@x402/svm/batch-settlement/facilitator"; import { ExactSvmScheme } from "@x402/svm/exact/facilitator"; import { UptoSvmScheme } from "@x402/svm/upto/facilitator"; import { ExactSvmSchemeV1 } from "@x402/svm/exact/v1/facilitator"; @@ -81,7 +94,10 @@ import { type FacilitatorHighloadV3Signer, } from "@x402/tvm"; import { ExactTvmScheme } from "@x402/tvm/exact/facilitator"; -import { createFacilitatorNearSigner, type FacilitatorNearSignerConfig } from "@x402/near"; +import { + createFacilitatorNearSigner, + type FacilitatorNearSignerConfig, +} from "@x402/near"; import { ExactNearScheme as ExactNearFacilitatorScheme } from "@x402/near/exact/facilitator"; import { ExactXrplScheme as ExactXrplFacilitatorScheme } from "@x402/xrpl/exact/facilitator"; import * as KeetaNet from "@keetanetwork/keetanet-client"; @@ -134,7 +150,9 @@ const APTOS_RPC_URL = process.env.APTOS_RPC_URL; const CASPER_RPC_URL = process.env.CASPER_RPC_URL; const HEDERA_RPC_URL = process.env.HEDERA_RPC_URL; const STELLAR_RPC_URL = process.env.STELLAR_RPC_URL; -const TVM_PROVIDER = (process.env.TVM_PROVIDER || TVM_PROVIDER_TONCENTER).toLowerCase(); +const TVM_PROVIDER = ( + process.env.TVM_PROVIDER || TVM_PROVIDER_TONCENTER +).toLowerCase(); // Map CAIP-2 network IDs to viem chains function getEvmChain(network: string): Chain { @@ -172,14 +190,18 @@ const hasFacilitatorCredential = [ process.env.FACILITATOR_EVM_PRIVATE_KEY, process.env.FACILITATOR_SVM_PRIVATE_KEY, process.env.FACILITATOR_APTOS_PRIVATE_KEY, - process.env.FACILITATOR_CASPER_PRIVATE_KEY && process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM, + process.env.FACILITATOR_CASPER_PRIVATE_KEY && + process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM, process.env.FACILITATOR_AVM_PRIVATE_KEY, - process.env.FACILITATOR_HEDERA_ACCOUNT_ID && process.env.FACILITATOR_HEDERA_PRIVATE_KEY, + process.env.FACILITATOR_HEDERA_ACCOUNT_ID && + process.env.FACILITATOR_HEDERA_PRIVATE_KEY, process.env.FACILITATOR_KEETA_MNEMONIC, process.env.FACILITATOR_STELLAR_PRIVATE_KEY, process.env.FACILITATOR_TVM_PRIVATE_KEY, - process.env.FACILITATOR_NEAR_ACCOUNT_ID && process.env.FACILITATOR_NEAR_PRIVATE_KEY, - process.env.FACILITATOR_CCD_PRIVATE_KEY && process.env.FACILITATOR_CCD_ADDRESS, + process.env.FACILITATOR_NEAR_ACCOUNT_ID && + process.env.FACILITATOR_NEAR_PRIVATE_KEY, + process.env.FACILITATOR_CCD_PRIVATE_KEY && + process.env.FACILITATOR_CCD_ADDRESS, process.env.XRPL_NETWORK, // keyless XRPL facilitator process.env.BLOCKFROST_PROJECT_ID, // Cardano runs provider-only without a mnemonic ].some(Boolean); @@ -191,9 +213,12 @@ if (!hasFacilitatorCredential) { // Initialize the EVM account from private key when configured const evmAccount = process.env.FACILITATOR_EVM_PRIVATE_KEY - ? privateKeyToAccount(process.env.FACILITATOR_EVM_PRIVATE_KEY as `0x${string}`, { - nonceManager, - }) + ? privateKeyToAccount( + process.env.FACILITATOR_EVM_PRIVATE_KEY as `0x${string}`, + { + nonceManager, + }, + ) : undefined; if (evmAccount) { console.info(`EVM Facilitator account: ${evmAccount.address}`); @@ -203,12 +228,12 @@ if (evmAccount) { // delegate to this address or supply their own (SERVER_EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY). const authorizerSigner: AuthorizerSigner | undefined = evmAccount ? { - address: evmAccount.address, - signTypedData: (params) => - evmAccount.signTypedData( - params as Parameters[0], - ), - } + address: evmAccount.address, + signTypedData: (params) => + evmAccount.signTypedData( + params as Parameters[0], + ), + } : undefined; if (authorizerSigner) { console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); @@ -217,8 +242,8 @@ if (authorizerSigner) { // Initialize the SVM account from private key when configured const svmAccount = process.env.FACILITATOR_SVM_PRIVATE_KEY ? await createKeyPairSignerFromBytes( - base58.decode(process.env.FACILITATOR_SVM_PRIVATE_KEY as string), - ) + base58.decode(process.env.FACILITATOR_SVM_PRIVATE_KEY as string), + ) : undefined; if (svmAccount) { console.info(`SVM Facilitator account: ${svmAccount.address}`); @@ -241,7 +266,9 @@ if (process.env.FACILITATOR_APTOS_PRIVATE_KEY) { // Initialize the AVM signer from private key (optional) let avmSigner: ReturnType | undefined; if (process.env.FACILITATOR_AVM_PRIVATE_KEY) { - avmSigner = toFacilitatorAvmSigner(process.env.FACILITATOR_AVM_PRIVATE_KEY as string); + avmSigner = toFacilitatorAvmSigner( + process.env.FACILITATOR_AVM_PRIVATE_KEY as string, + ); console.info(`AVM Facilitator account: ${avmSigner.getAddresses()[0]}`); } @@ -249,20 +276,29 @@ let casperFacilitatorSigner: FacilitatorCasperSigner | undefined; if (process.env.FACILITATOR_CASPER_PRIVATE_KEY) { casperFacilitatorSigner = await createFacilitatorCasperSigner( process.env.FACILITATOR_CASPER_PRIVATE_KEY, - process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" ? 2 : 1, // Default to ED25519 if not specified); + process.env.FACILITATOR_CASPER_PRIVATE_KEY_ALGORITHM === "secp256k1" + ? 2 + : 1, // Default to ED25519 if not specified); { - rpcUrlConfig: CASPER_RPC_URL ? { [`${CASPER_NETWORK}`]: CASPER_RPC_URL } : undefined, + rpcUrlConfig: CASPER_RPC_URL + ? { [`${CASPER_NETWORK}`]: CASPER_RPC_URL } + : undefined, speculativeRpcUrlConfig: process.env.CASPER_SPECULATIVE_RPC_URL ? { [`${CASPER_NETWORK}`]: process.env.CASPER_SPECULATIVE_RPC_URL } : undefined, }, ); - console.info(`Casper Facilitator account: ${casperFacilitatorSigner.getAddresses(CASPER_NETWORK as Network)[0]}`); + console.info( + `Casper Facilitator account: ${casperFacilitatorSigner.getAddresses(CASPER_NETWORK as Network)[0]}`, + ); } // Initialize the Hedera signer from account + private key (optional) let hederaSigner: ReturnType | undefined; -if (process.env.FACILITATOR_HEDERA_ACCOUNT_ID && process.env.FACILITATOR_HEDERA_PRIVATE_KEY) { +if ( + process.env.FACILITATOR_HEDERA_ACCOUNT_ID && + process.env.FACILITATOR_HEDERA_PRIVATE_KEY +) { const hederaAccountId = process.env.FACILITATOR_HEDERA_ACCOUNT_ID; const hederaKey = HederaPrivateKey.fromStringECDSA( process.env.FACILITATOR_HEDERA_PRIVATE_KEY, @@ -289,10 +325,14 @@ if (process.env.FACILITATOR_HEDERA_ACCOUNT_ID && process.env.FACILITATOR_HEDERA_ let keetaSigner: FacilitatorKeetaSigner | undefined; if (process.env.FACILITATOR_KEETA_MNEMONIC) { const keetaAccount = KeetaNet.lib.Account.fromSeed( - await KeetaNet.lib.Account.seedFromPassphrase(process.env.FACILITATOR_KEETA_MNEMONIC), + await KeetaNet.lib.Account.seedFromPassphrase( + process.env.FACILITATOR_KEETA_MNEMONIC, + ), 0, ); - console.info(`Keeta Facilitator account: ${keetaAccount.publicKeyString.toString()}`); + console.info( + `Keeta Facilitator account: ${keetaAccount.publicKeyString.toString()}`, + ); keetaSigner = toFacilitatorKeetaSigner([keetaAccount]); } @@ -309,21 +349,29 @@ if (process.env.FACILITATOR_STELLAR_PRIVATE_KEY) { // Initialize the TVM highload signer from private key (optional) let tvmSigner: FacilitatorHighloadV3Signer | undefined; if (process.env.FACILITATOR_TVM_PRIVATE_KEY) { - const tvmConfig = HighloadV3Config.fromPrivateKey(process.env.FACILITATOR_TVM_PRIVATE_KEY, { - provider: TVM_PROVIDER, - apiKey: - TVM_PROVIDER === TVM_PROVIDER_TONAPI - ? process.env.TVM_TONAPI_API_KEY - : process.env.TVM_TONCENTER_API_KEY, - providerBaseUrl: process.env.TVM_RPC_URL, - }); + const tvmConfig = HighloadV3Config.fromPrivateKey( + process.env.FACILITATOR_TVM_PRIVATE_KEY, + { + provider: TVM_PROVIDER, + apiKey: + TVM_PROVIDER === TVM_PROVIDER_TONAPI + ? process.env.TVM_TONAPI_API_KEY + : process.env.TVM_TONCENTER_API_KEY, + providerBaseUrl: process.env.TVM_RPC_URL, + }, + ); tvmSigner = toFacilitatorTvmSigner({ [TVM_NETWORK]: tvmConfig }); - console.info(`TVM Facilitator account: ${tvmSigner.getAddressesForNetwork(TVM_NETWORK)[0]}`); + console.info( + `TVM Facilitator account: ${tvmSigner.getAddressesForNetwork(TVM_NETWORK)[0]}`, + ); } // Initialize the NEAR facilitator (relayer) signer from account + key (optional) let nearSigner: ReturnType | undefined; -if (process.env.FACILITATOR_NEAR_ACCOUNT_ID && process.env.FACILITATOR_NEAR_PRIVATE_KEY) { +if ( + process.env.FACILITATOR_NEAR_ACCOUNT_ID && + process.env.FACILITATOR_NEAR_PRIVATE_KEY +) { nearSigner = createFacilitatorNearSigner({ relayers: [ { @@ -334,7 +382,9 @@ if (process.env.FACILITATOR_NEAR_ACCOUNT_ID && process.env.FACILITATOR_NEAR_PRIV ], rpcUrls: NEAR_RPC_URL ? { [NEAR_NETWORK]: NEAR_RPC_URL } : undefined, }); - console.info(`NEAR Facilitator relayer: ${process.env.FACILITATOR_NEAR_ACCOUNT_ID}`); + console.info( + `NEAR Facilitator relayer: ${process.env.FACILITATOR_NEAR_ACCOUNT_ID}`, + ); } let cardanoSigner: ReturnType | undefined; @@ -343,7 +393,10 @@ if (process.env.BLOCKFROST_PROJECT_ID && CARDANO_RPC_URL) { mnemonic: process.env.FACILITATOR_CARDANO_MNEMONIC, network: CARDANO_NETWORK, provider: { - blockfrost: { baseUrl: CARDANO_RPC_URL, projectId: process.env.BLOCKFROST_PROJECT_ID }, + blockfrost: { + baseUrl: CARDANO_RPC_URL, + projectId: process.env.BLOCKFROST_PROJECT_ID, + }, }, awaitConfirmation: false, }); @@ -352,84 +405,121 @@ if (process.env.BLOCKFROST_PROJECT_ID && CARDANO_RPC_URL) { ); } -let concordiumSigner: ReturnType | undefined; -if (process.env.FACILITATOR_CCD_PRIVATE_KEY && process.env.FACILITATOR_CCD_ADDRESS) { +let concordiumSigner: + | ReturnType + | undefined; +if ( + process.env.FACILITATOR_CCD_PRIVATE_KEY && + process.env.FACILITATOR_CCD_ADDRESS +) { const [host, port] = parseGrpcUrl(CCD_RPC_URL); concordiumSigner = toConcordiumFacilitatorSigner( process.env.FACILITATOR_CCD_ADDRESS, process.env.FACILITATOR_CCD_PRIVATE_KEY, { host, port, useTls: true }, ); - console.info(`CCD Facilitator account: ${process.env.FACILITATOR_CCD_ADDRESS} on ${CCD_NETWORK} (private key)`); + console.info( + `CCD Facilitator account: ${process.env.FACILITATOR_CCD_ADDRESS} on ${CCD_NETWORK} (private key)`, + ); } // Create a Viem client with both wallet and public capabilities when EVM is configured const evmChain = getEvmChain(EVM_NETWORK); const viemClient = evmAccount ? createWalletClient({ - account: evmAccount, - chain: evmChain, - transport: http(EVM_RPC_URL), - }).extend(publicActions) + account: evmAccount, + chain: evmChain, + transport: http(EVM_RPC_URL), + }).extend(publicActions) : undefined; -const evmSigner = evmAccount && viemClient - ? toFacilitatorEvmSigner({ - address: evmAccount.address, - readContract: (args: { - address: `0x${string}`; - abi: readonly unknown[]; - functionName: string; - args?: readonly unknown[]; - }) => - viemClient.readContract({ - ...args, - args: args.args || [], - }), - verifyTypedData: (args: { - address: `0x${string}`; - domain: Record; - types: Record; - primaryType: string; - message: Record; - signature: `0x${string}`; - }) => viemClient.verifyTypedData(args as any), - writeContract: (args: { - address: `0x${string}`; - abi: readonly unknown[]; - functionName: string; - args: readonly unknown[]; - gas?: bigint; - }) => - viemClient.writeContract({ - ...args, - args: args.args || [], - gas: args.gas, - }), - sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => - viemClient.sendTransaction(args), - waitForTransactionReceipt: (args: { hash: `0x${string}` }) => - viemClient.waitForTransactionReceipt(args), - getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), - }) - : undefined; +const evmSigner = + evmAccount && viemClient + ? toFacilitatorEvmSigner({ + address: evmAccount.address, + readContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + viemClient.readContract({ + ...args, + args: args.args || [], + }), + verifyTypedData: (args: { + address: `0x${string}`; + domain: Record; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }) => viemClient.verifyTypedData(args as any), + writeContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args: readonly unknown[]; + gas?: bigint; + }) => + viemClient.writeContract({ + ...args, + args: args.args || [], + gas: args.gas, + }), + sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => + viemClient.sendTransaction(args), + waitForTransactionReceipt: (args: { hash: `0x${string}` }) => + viemClient.waitForTransactionReceipt(args), + getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), + }) + : undefined; // Facilitator can now handle all Solana networks with automatic RPC creation // Pass custom RPC URL if provided const svmSigner = svmAccount ? toFacilitatorSvmSigner( - svmAccount, - SVM_RPC_URL ? { defaultRpcUrl: SVM_RPC_URL } : undefined, - ) + svmAccount, + SVM_RPC_URL ? { defaultRpcUrl: SVM_RPC_URL } : undefined, + ) : undefined; +/** SVM batch-settlement facilitator binding source (in-memory store and/or archive RPC history). */ +function buildSvmBatchFacilitatorConfig(network: Network): BatchSvmFacilitatorConfig { + const bindingStore = process.env.FACILITATOR_SVM_BATCH_BINDING_STORE?.trim().toLowerCase(); + const useInMemoryStore = + bindingStore === undefined || + bindingStore === "" || + bindingStore === "memory" || + bindingStore === "inmemory" || + bindingStore === "true" || + bindingStore === "1"; + const archiveRpcUrl = process.env.SVM_ARCHIVE_RPC_URL?.trim(); + + const config: BatchSvmFacilitatorConfig = {}; + if (useInMemoryStore) { + config.receiverAuthorizerStore = new InMemoryBatchReceiverAuthorizerStore(); + } + if (archiveRpcUrl) { + config.receiverBindingHistoryReader = createReceiverBindingHistoryReader({ + [network]: archiveRpcUrl, + }); + console.info(`SVM batch-settlement binding history RPC: ${archiveRpcUrl}`); + } else if (!useInMemoryStore) { + console.info( + "SVM batch-settlement: in-memory receiver binding store disabled; using signer RPC history reads", + ); + } + return config; +} + // Facilitator can handle all Aptos networks with automatic RPC creation // Pass custom RPC URL if provided const aptosSigner = aptosAccount ? toFacilitatorAptosSigner( - aptosAccount, - APTOS_RPC_URL ? { defaultRpcUrl: APTOS_RPC_URL } : undefined, - ) + aptosAccount, + APTOS_RPC_URL ? { defaultRpcUrl: APTOS_RPC_URL } : undefined, + ) : undefined; const verifiedPayments = new Map(); @@ -481,7 +571,8 @@ function extractPayloadAction(paymentPayload: PaymentPayload): string { } // Minimal ABI fragment for reading channel state from the BatchSettlement contract -const BATCH_SETTLEMENT_ADDRESS = "0x4020e07E964De72a79367828c9C6140fcaE00003" as const; +const BATCH_SETTLEMENT_ADDRESS = + "0x4020e07E964De72a79367828c9C6140fcaE00003" as const; const channelsAbi = [ { type: "function", @@ -512,7 +603,11 @@ async function readChannelBalance(channelId: `0x${string}`): Promise { async function waitForChannelDepositConfirmed( channelId: `0x${string}`, expectedMinBalance: bigint, - options: { initialDelayMs?: number; maxDelayMs?: number; timeoutMs?: number } = {}, + options: { + initialDelayMs?: number; + maxDelayMs?: number; + timeoutMs?: number; + } = {}, ): Promise { const initialDelayMs = options.initialDelayMs ?? 250; const maxDelayMs = options.maxDelayMs ?? 4_000; @@ -552,8 +647,12 @@ async function waitForChannelDepositConfirmed( async function waitForBatchSettlementDepositConfirmed( extra: Record | undefined, ): Promise { - const channelId = typeof extra?.channelId === "string" ? (extra.channelId as `0x${string}`) : undefined; - const balanceStr = typeof extra?.balance === "string" ? extra.balance : undefined; + const channelId = + typeof extra?.channelId === "string" + ? (extra.channelId as `0x${string}`) + : undefined; + const balanceStr = + typeof extra?.balance === "string" ? extra.balance : undefined; if (!channelId || !balanceStr) { console.warn( @@ -566,7 +665,9 @@ async function waitForBatchSettlementDepositConfirmed( try { expectedMinBalance = BigInt(balanceStr); } catch { - console.warn(`⏳ deposit confirm: unparseable balance ${balanceStr}, skipping wait`); + console.warn( + `⏳ deposit confirm: unparseable balance ${balanceStr}, skipping wait`, + ); return; } @@ -604,9 +705,10 @@ if (svmSigner) { enableSmartWalletVerification: true, }), ) + .register(SVM_NETWORK as Network, new UptoSvmScheme(svmSigner)) .register( SVM_NETWORK as Network, - new UptoSvmScheme(svmSigner), + new BatchSettlementSvmScheme(svmSigner, buildSvmBatchFacilitatorConfig(SVM_NETWORK as Network)), ) .registerV1(SVM_V1_NETWORKS as Network[], new ExactSvmSchemeV1(svmSigner)); } @@ -632,7 +734,10 @@ if (hederaSigner) { ); } if (keetaSigner) { - facilitator.register(KEETA_NETWORK as Network, new ExactKeetaScheme(keetaSigner, console)); + facilitator.register( + KEETA_NETWORK as Network, + new ExactKeetaScheme(keetaSigner, console), + ); } if (stellarSigner) { facilitator.register( @@ -644,16 +749,27 @@ if (tvmSigner) { facilitator.register(TVM_NETWORK as Network, new ExactTvmScheme(tvmSigner)); } if (nearSigner) { - facilitator.register(NEAR_NETWORK as Network, new ExactNearFacilitatorScheme(nearSigner)); + facilitator.register( + NEAR_NETWORK as Network, + new ExactNearFacilitatorScheme(nearSigner), + ); } if (process.env.XRPL_NETWORK) { facilitator.register( XRPL_NETWORK as Network, new ExactXrplFacilitatorScheme( - XRPL_RPC_URL ? { wsUrlByNetwork: { [XRPL_NETWORK as `xrpl:${number}`]: XRPL_RPC_URL } } : {}, + XRPL_RPC_URL + ? { + wsUrlByNetwork: { + [XRPL_NETWORK as `xrpl:${number}`]: XRPL_RPC_URL, + }, + } + : {}, ), ); - console.info(`XRPL facilitator enabled on ${XRPL_NETWORK} (payer-signed; no facilitator signer)`); + console.info( + `XRPL facilitator enabled on ${XRPL_NETWORK} (payer-signed; no facilitator signer)`, + ); } if (concordiumSigner) { facilitator.register( @@ -670,7 +786,6 @@ if (cardanoSigner) { ); } - facilitator.registerExtension(BAZAAR); if (evmSigner && viemClient) { @@ -735,7 +850,9 @@ if (evmSigner && viemClient) { facilitator .registerExtension(EIP2612_GAS_SPONSORING) - .registerExtension(createErc20ApprovalGasSponsoringExtension(erc20ApprovalSigner)); + .registerExtension( + createErc20ApprovalGasSponsoringExtension(erc20ApprovalSigner), + ); } facilitator @@ -747,13 +864,13 @@ facilitator verifiedPayments.set(paymentHash, Date.now()); // Hook 2: Extract and catalog bazaar discovery info - const discovered = extractDiscoveryInfo( - context.paymentPayload, - context.requirements, - ); - if (discovered) { - const action = - "toolName" in discovered ? discovered.toolName : discovered.method; + const discovered = extractDiscoveryInfo( + context.paymentPayload, + context.requirements, + ); + if (discovered) { + const action = + "toolName" in discovered ? discovered.toolName : discovered.method; if (!action) { return; } @@ -824,7 +941,10 @@ facilitator // server's automatic retry with the same payload (core's // settleWithPendingRetry); that retry must still pass Hook 3, so the // verified-payment record is kept until a terminal outcome. - if (!context.result.success && context.result.errorReason === "settlement_pending") { + if ( + !context.result.success && + context.result.errorReason === "settlement_pending" + ) { console.log( `⏳ Settlement pending: ${context.result.transaction} (${JSON.stringify(context.result.extra ?? {})})`, ); @@ -839,9 +959,12 @@ facilitator console.log(`✅ Settlement completed: ${context.result.transaction}`); } - // For batch-settlement deposits, wait for the deposit to be confirmed onchain + // For EVM batch-settlement deposits, wait for the deposit to be confirmed + // onchain by reading the BatchSettlement contract. The SVM facilitator + // confirms the open before it answers, so no read-back is needed there. if ( isBatchSettlementScheme(context.requirements) && + context.requirements.network.startsWith("eip155:") && context.result.success && extractPayloadAction(context.paymentPayload) === "deposit" ) { @@ -1008,9 +1131,13 @@ app.get("/health", (req, res) => { svmNetwork: SVM_NETWORK, avmNetwork: avmSigner ? AVM_NETWORK : "(not configured)", aptosNetwork: aptosAccount ? APTOS_NETWORK : "(not configured)", - casperNetwork: casperFacilitatorSigner ? CASPER_NETWORK : "(not configured)", + casperNetwork: casperFacilitatorSigner + ? CASPER_NETWORK + : "(not configured)", hederaNetwork: hederaSigner ? HEDERA_NETWORK : "(not configured)", - keetaNetwork: process.env.FACILITATOR_KEETA_MNEMONIC ? KEETA_NETWORK : "(not configured)", + keetaNetwork: process.env.FACILITATOR_KEETA_MNEMONIC + ? KEETA_NETWORK + : "(not configured)", stellarNetwork: stellarSigner ? STELLAR_NETWORK : "(not configured)", nearNetwork: nearSigner ? NEAR_NETWORK : "(not configured)", xrplNetwork: process.env.XRPL_NETWORK ? XRPL_NETWORK : "(not configured)", diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index bd1f10924c..b02e1b7e1c 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -1966,6 +1966,9 @@ importers: '@solana/kit': specifier: '>=5.1.0' version: 5.3.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3)(utf-8-validate@5.0.10) + '@solana/program-client-core': + specifier: ^6.1.0 + version: 6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.8.3) '@x402/core': specifier: workspace:~ version: link:../../core diff --git a/e2e/servers/typescript/catalog.ts b/e2e/servers/typescript/catalog.ts index 16d10a1476..9afde5edbf 100644 --- a/e2e/servers/typescript/catalog.ts +++ b/e2e/servers/typescript/catalog.ts @@ -4,6 +4,7 @@ * Everything the express/hono/fastify servers mount comes from here, so adding a * mechanism means adding a catalog entry rather than editing each framework. */ +import { masumiEscrowAddress } from "@x402/cardano"; import { CLOSE_PATH, HEALTH_PATH, @@ -35,7 +36,7 @@ export function catalogRoutes(): SdkRoute[] { export function resolvedRoutes(cfg: ServerEnvConfig): ResolvedRoute[] { const env = (key: string): string | undefined => (cfg as unknown as Record)[key] ?? process.env[key]; - return resolvePaymentRoutes(SDK, env, routeFilter); + return resolvePaymentRoutes(SDK, env, routeFilter, masumiEscrowAddress); } export type ServedNetwork = { diff --git a/e2e/servers/typescript/config.ts b/e2e/servers/typescript/config.ts index 2d58a9ade8..351ea0cb2d 100644 --- a/e2e/servers/typescript/config.ts +++ b/e2e/servers/typescript/config.ts @@ -2,6 +2,7 @@ import { ExactAvmScheme } from "@x402/avm/exact/server"; import { ExactEvmScheme } from "@x402/evm/exact/server"; import { UptoEvmScheme } from "@x402/evm/upto/server"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; +import { BatchSvmScheme as BatchSettlementSvmScheme } from "@x402/svm/batch-settlement/server"; import { ExactSvmScheme } from "@x402/svm/exact/server"; import { UptoSvmScheme } from "@x402/svm/upto/server"; import { base58 } from "@scure/base"; @@ -105,19 +106,32 @@ async function registerFamilySchemes( case "svm": { server.register(pattern, new ExactSvmScheme()); const receiverAuthorizerPrivateKey = process.env.SERVER_SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY; - if (receiverAuthorizerPrivateKey) { - const receiverAuthorizerSigner = await createKeyPairSignerFromBytes( - base58.decode(receiverAuthorizerPrivateKey), - ); - console.info(`SVM receiver authorizer: ${receiverAuthorizerSigner.address}`); - server.register( - pattern, - new UptoSvmScheme({ - receiverAuthorizerSigner, - rpcUrl: process.env.SVM_RPC_URL, - }), - ); + const receiverAuthorizerSigner = receiverAuthorizerPrivateKey + ? await createKeyPairSignerFromBytes(base58.decode(receiverAuthorizerPrivateKey)) + : undefined; + if (!receiverAuthorizerSigner) return; + console.info(`SVM receiver authorizer: ${receiverAuthorizerSigner.address}`); + server.register( + pattern, + new UptoSvmScheme({ + receiverAuthorizerSigner, + rpcUrl: process.env.SVM_RPC_URL, + }), + ); + const operatorPrivateKey = process.env.SERVER_SVM_OPERATOR_PRIVATE_KEY; + const operatorSigner = operatorPrivateKey + ? await createKeyPairSignerFromBytes(base58.decode(operatorPrivateKey)) + : undefined; + if (operatorSigner) { + console.info(`SVM batch-settlement operator: ${operatorSigner.address}`); } + server.register( + pattern, + new BatchSettlementSvmScheme({ + receiverAuthorizer: receiverAuthorizerSigner, + ...(operatorSigner ? { operator: operatorSigner } : {}), + }), + ); return; } case "aptos": diff --git a/e2e/src/clients/generic-client.ts b/e2e/src/clients/generic-client.ts index 7dbd308b7e..2061e21e8a 100644 --- a/e2e/src/clients/generic-client.ts +++ b/e2e/src/clients/generic-client.ts @@ -29,6 +29,10 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { ENDPOINT_PATH: config.endpointPath, ...(config.batchSettlement ? { + // Family-neutral names; the EVM_ aliases stay for the Go and + // Python clients, which only run batch-settlement on EVM. + BATCH_SETTLEMENT_CHANNEL: config.batchSettlement.channelSalt, + BATCH_SETTLEMENT_PHASE: config.batchSettlement.phase, EVM_BATCH_SETTLEMENT_CHANNEL: config.batchSettlement.channelSalt, EVM_BATCH_SETTLEMENT_PHASE: config.batchSettlement.phase, ...(config.batchSettlement.voucherSignerPrivateKey @@ -37,6 +41,12 @@ export class GenericClientProxy extends BaseProxy implements ClientProxy { config.batchSettlement.voucherSignerPrivateKey, } : {}), + ...(config.batchSettlement.svmServerSignedOperators + ? { + CLIENT_SVM_SERVER_SIGNED_OPERATORS: + config.batchSettlement.svmServerSignedOperators, + } + : {}), } : {}), }; diff --git a/e2e/src/mechanisms.ts b/e2e/src/mechanisms.ts index 208a270668..2a5a03b311 100644 --- a/e2e/src/mechanisms.ts +++ b/e2e/src/mechanisms.ts @@ -17,7 +17,6 @@ import { readFileSync, readdirSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import type { PaymentFlowName } from '@x402/core/types'; -import { masumiEscrowAddress } from '@x402/cardano'; /** always-succeeds Plutus V3 fixture (same bytes as cardano test stubs). */ const CARDANO_ALWAYS_SUCCEEDS_SCRIPT = '4d01000033222220051200120011'; @@ -137,6 +136,8 @@ export type RouteDefinition = { requiresEnv?: string; /** Payment completion window advertised on this route. */ maxTimeoutSeconds?: number; + /** Merged into the route payment option's `extra` (wire `PaymentRequirements.extra`). */ + requirementsExtra?: Record; }; /** Fixed success body for every paid route (`timestamp` is added by the server). */ @@ -896,6 +897,7 @@ function resolvePayTo( caip2: string, serverPayTo: string | undefined, env: EnvLookup, + masumiEscrowAddress: (network: string) => string, ): string | undefined { if (route.network !== 'cardano') { return serverPayTo; @@ -990,7 +992,8 @@ function resolvePrice( export function resolvePaymentRoutes( sdk: string, env: EnvLookup, - filter?: RouteFilter, + filter: RouteFilter | undefined, + masumiEscrowAddress: (network: string) => string, ): ResolvedRoute[] { const resolved: ResolvedRoute[] = []; @@ -1000,11 +1003,17 @@ export function resolvePaymentRoutes( if (!serverPayTo) continue; const caip2 = env(derivedNetworkKey(route.network)) ?? def.networks.testnet.caip2; - const payTo = resolvePayTo(route, caip2, serverPayTo, env); + const payTo = resolvePayTo(route, caip2, serverPayTo, env, masumiEscrowAddress); if (!payTo) continue; const { price, extra: priceExtra } = resolvePrice(route, caip2, env); - const extra = mergeRouteExtra(priceExtra, cardanoRouteExtra(route, env), route.paymentFlow); + const extra = mergeRouteExtra( + priceExtra, + route.requirementsExtra + ? { ...route.requirementsExtra, ...cardanoRouteExtra(route, env) } + : cardanoRouteExtra(route, env), + route.paymentFlow, + ); resolved.push({ path: route.path, diff --git a/e2e/src/types.ts b/e2e/src/types.ts index 7e2038bc9c..8bac933adf 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -74,6 +74,8 @@ export interface BatchSettlementClientConfig { phase: BatchSettlementPhase; /** Optional alternate EOA used to sign vouchers (deposits still use the main client signer). */ voucherSignerPrivateKey?: string; + /** Trusted SVM batch operator pubkeys for server-signed voucher mode (base58, comma-separated). */ + svmServerSignedOperators?: string; } export interface ClientConfig { serverUrl: string; diff --git a/e2e/test.ts b/e2e/test.ts index 8c44907f56..6f7a331c51 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -22,6 +22,8 @@ import { FacilitatorManager } from './src/facilitators/facilitator-manager'; import { waitForHealth } from './src/health'; import { probeMcpReady } from './src/mcpHealth'; import { createPortAllocator } from './src/ports'; +import { base58 } from '@scure/base'; +import { createKeyPairSignerFromBytes } from '@solana/kit'; /** * Generates a fresh 32-byte hex salt for a batch-settlement test scenario so @@ -29,6 +31,27 @@ import { createPortAllocator } from './src/ports'; * * @returns Hex-encoded 32-byte salt prefixed with `0x`. */ +/** + * Operator pubkeys the SVM client should trust for server-signed batch routes. + * Uses CLIENT_SVM_SERVER_SIGNED_OPERATORS when set; otherwise derives the pubkey + * from SERVER_SVM_OPERATOR_PRIVATE_KEY for /batch-settlement-server-signed/* routes. + */ +async function resolveSvmServerSignedOperators(endpointPath: string): Promise { + const explicit = process.env.CLIENT_SVM_SERVER_SIGNED_OPERATORS?.trim(); + if (explicit) { + return explicit; + } + if (!endpointPath.includes('/batch-settlement-server-signed/')) { + return undefined; + } + const operatorKey = process.env.SERVER_SVM_OPERATOR_PRIVATE_KEY?.trim(); + if (!operatorKey) { + return undefined; + } + const signer = await createKeyPairSignerFromBytes(base58.decode(operatorKey)); + return signer.address; +} + function generateChannelSalt(): `0x${string}` { const bytes = new Uint8Array(32); crypto.getRandomValues(bytes); @@ -724,6 +747,17 @@ function envFlagDefaultTrue(value: string | undefined): boolean { return !['0', 'false', 'no', 'off'].includes(value.toLowerCase()); } +function batchSettlementRecoveryForFamily(protocolFamily: string): boolean { + switch (protocolFamily) { + case 'evm': + return envFlagDefaultTrue(process.env.EVM_BATCH_SETTLEMENT_RECOVERY); + case 'svm': + return envFlagDefaultTrue(process.env.SVM_BATCH_SETTLEMENT_RECOVERY); + default: + return true; + } +} + function waitForChildProcess(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve(true); @@ -789,8 +823,6 @@ async function runTest() { const facilitatorEvmPrivateKey = process.env.FACILITATOR_EVM_PRIVATE_KEY; const facilitatorHederaAccountId = process.env.FACILITATOR_HEDERA_ACCOUNT_ID; const facilitatorHederaPrivateKey = process.env.FACILITATOR_HEDERA_PRIVATE_KEY; - const batchSettlementRecovery = envFlagDefaultTrue(process.env.EVM_BATCH_SETTLEMENT_RECOVERY); - // Discover all servers, clients, and facilitators (always include legacy) const discovery = new TestDiscovery('.'); @@ -1353,12 +1385,14 @@ async function runTest() { if (isBatchSettlement) { const channelSalt = generateChannelSalt(); + const svmServerSignedOperators = await resolveSvmServerSignedOperators(scenario.endpoint.path); const batchBase = { channelSalt, ...(voucherSignerPrivateKey ? { voucherSignerPrivateKey } : {}), + ...(svmServerSignedOperators ? { svmServerSignedOperators } : {}), }; - if (!batchSettlementRecovery) { + if (!batchSettlementRecoveryForFamily(scenario.protocolFamily)) { const fullResult = await runClientTest(scenario.client.proxy, { ...baseClientConfig, batchSettlement: { ...batchBase, phase: 'full' }, diff --git a/examples/typescript/clients/batch-settlement/.env-local b/examples/typescript/clients/batch-settlement/.env-local index 2681759326..79a3f099c1 100644 --- a/examples/typescript/clients/batch-settlement/.env-local +++ b/examples/typescript/clients/batch-settlement/.env-local @@ -1,20 +1,34 @@ +# At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required. EVM_PRIVATE_KEY=0x... +SVM_PRIVATE_KEY= -# Optional: dedicated voucher-signing EOA (faster verification, recommended for smart wallets) +# EVM: optional dedicated voucher-signing EOA (faster verification, recommended for smart wallets) EVM_VOUCHER_SIGNER_PRIVATE_KEY= +SVM_RPC_URL= +# SVM: comma-separated base58 operator keys allowed to run server-signed (metered) +# channels for this client. The operator of such a channel can claim up to the whole +# escrow, so leave this empty unless you trust the operator; the client then pays +# client-signed vouchers only. +SVM_SERVER_SIGNED_OPERATORS= +# SVM: USD cap on the escrow locked per channel under a trusted operator (default $0.05). +SVM_SERVER_SIGNED_MAX_DEPOSIT= + RESOURCE_SERVER_URL=http://localhost:4021 ENDPOINT_PATH=/weather -# 32-byte hex; differentiates channels with the same payer/payee/token tuple +# EVM: 32-byte hex; differentiates channels with the same payer/payee/token tuple CHANNEL_SALT=0x0000000000000000000000000000000000000000000000000000000000000000 +# SVM: decimal u64 channel salt (default 0); change to open a fresh channel on purpose +SVM_CHANNEL_SALT=0 -# Optional: persist session state across runs +# Optional: persist session state across runs (EVM file storage) STORAGE_DIR= NUMBER_OF_REQUESTS=3 +DEPOSIT_MULTIPLIER=5 -# After the request loop completes, request a cooperative refund. +# After the request loop completes, request a cooperative refund (EVM only). REFUND_AFTER_REQUESTS=false # Optional partial refund amount (base units). Empty drains the remaining channel balance. REFUND_AMOUNT= diff --git a/examples/typescript/clients/batch-settlement/README.md b/examples/typescript/clients/batch-settlement/README.md index bd5854bd53..f0ad9235ca 100644 --- a/examples/typescript/clients/batch-settlement/README.md +++ b/examples/typescript/clients/batch-settlement/README.md @@ -1,9 +1,8 @@ # Batch-Settlement Client Example -Fetch-based client that pays for a sequence of requests over a single payment channel using the **batch-settlement** EVM scheme. The first request opens the channel with a deposit; subsequent requests pay with a fresh cumulative voucher. +Fetch-based client that pays for a sequence of requests over a single payment channel using the **batch-settlement** EVM or SVM scheme. The first request opens the channel with a deposit; subsequent requests pay with a fresh cumulative voucher. - -See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. +See the [EVM specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md), [SVM specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_svm.md), and [EVM scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. ## Voucher Signer Delegation @@ -51,13 +50,13 @@ const scheme = new BatchSettlementEvmScheme(signer, { - Node.js v20+, pnpm v10 - A running [batch-settlement server](../../servers/batch-settlement) -- A funded EVM `EVM_PRIVATE_KEY` holding the deposit token (USDC on Base Sepolia by default) +- A funded `EVM_PRIVATE_KEY` or `SVM_PRIVATE_KEY` holding the deposit token ## Setup ```bash cp .env-local .env -# fill EVM_PRIVATE_KEY (and optionally EVM_VOUCHER_SIGNER_PRIVATE_KEY) +# fill EVM_PRIVATE_KEY or SVM_PRIVATE_KEY cd ../../ pnpm install && pnpm build @@ -77,16 +76,21 @@ CONCURRENCY=3 NUMBER_OF_ROUNDS=3 pnpm dev:concurrent ## Environment | Variable | Required | Description | -|----------|----------|-------------| -| `EVM_PRIVATE_KEY` | yes | Payer key (funds the deposit) | +| --- | --- | --- | +| `EVM_PRIVATE_KEY` | one of | EVM payer key (funds the deposit) | | `EVM_VOUCHER_SIGNER_PRIVATE_KEY` | no | Dedicated voucher-signing EOA (committed as `payerAuthorizer`) | +| `SVM_PRIVATE_KEY` | one of | Base58-encoded SVM payer keypair bytes | +| `SVM_RPC_URL` | no | SVM RPC endpoint override | +| `SVM_SERVER_SIGNED_OPERATORS` | no | Comma-separated base58 operator keys allowed to sign vouchers for this client (server-signed, metered channels). Empty means server-signed accepts are refused and the client pays with its own vouchers. The operator of a server-signed channel can claim up to the whole escrow. Works over any transport. | +| `SVM_SERVER_SIGNED_MAX_DEPOSIT` | no | USD cap (e.g. `$0.05`, default) on the escrow locked per channel under a trusted operator, for default assets; server `minDeposit` hints above it are clamped. Non-default tokens need an `allowedAssets` entry with an atomic cap in code. | | `RESOURCE_SERVER_URL` | no | Server base URL (default `http://localhost:4021`) | | `ENDPOINT_PATH` | no | Path on the server (default `/weather`) | -| `CHANNEL_SALT` | no | `bytes32` salt for channel id; change to open a fresh channel | +| `CHANNEL_SALT` | no | EVM `bytes32` salt for channel id; change to open a fresh channel | +| `SVM_CHANNEL_SALT` | no | SVM decimal `u64` channel salt (default `0`); change to open a fresh channel | | `DEPOSIT_MULTIPLIER` | no | Deposit target is `amount ×` this multiplier when `extra.minDeposit` is absent; lock ceiling is `spendCap ×` this multiplier (integer **≥ 3**; default `5`) | | `STORAGE_DIR` | no | Persist client session state (defaults to in-memory) | | `NUMBER_OF_REQUESTS` | no | How many paid requests to issue (default 3) | | `CONCURRENCY` | no | How many channels to run in parallel in `pnpm dev:concurrent` (default 3) | | `NUMBER_OF_ROUNDS` | no | How many concurrent rounds to run in `pnpm dev:concurrent` (default 3) | | `REFUND_AFTER_REQUESTS` | no | If `true`, issue a self-contained refund via `scheme.refund(url)` after the request loop | -| `REFUND_AMOUNT` | no | Partial refund amount in base units; omit for a full refund | +| `REFUND_AMOUNT` | no | Partial EVM refund amount in base units; omit for a full refund. SVM supports full refund only. | diff --git a/examples/typescript/clients/batch-settlement/index.ts b/examples/typescript/clients/batch-settlement/index.ts index baf7d7687c..bc6975a153 100644 --- a/examples/typescript/clients/batch-settlement/index.ts +++ b/examples/typescript/clients/batch-settlement/index.ts @@ -1,7 +1,10 @@ +import { base58 } from "@scure/base"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; import { toClientEvmSigner } from "@x402/evm"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/client"; import { FileClientChannelStorage } from "@x402/evm/batch-settlement/client/file-storage"; import { x402Client, wrapFetchWithPayment, x402HTTPClient } from "@x402/fetch"; +import { BatchSvmScheme } from "@x402/svm/batch-settlement/client"; import { config } from "dotenv"; import { createPublicClient, http } from "viem"; import { baseSepolia } from "viem/chains"; @@ -10,12 +13,7 @@ import { privateKeyToAccount } from "viem/accounts"; config(); const evmPrivateKeyRaw = process.env.EVM_PRIVATE_KEY?.trim(); -if (!evmPrivateKeyRaw) { - console.error("EVM_PRIVATE_KEY environment variable is required"); - process.exit(1); -} -const evmPrivateKey = evmPrivateKeyRaw as `0x${string}`; -// Blank `KEY=` in .env is "" not undefined — treat as unset (same as optional Go env). +const svmPrivateKeyRaw = process.env.SVM_PRIVATE_KEY?.trim(); const evmVoucherSignerPrivateKey = process.env.EVM_VOUCHER_SIGNER_PRIVATE_KEY?.trim() || undefined; const baseURL = process.env.RESOURCE_SERVER_URL || "http://localhost:4021"; const endpointPath = process.env.ENDPOINT_PATH || "/weather"; @@ -23,10 +21,26 @@ const url = `${baseURL}${endpointPath}`; const storageDir = process.env.STORAGE_DIR; const channelSalt = (process.env.CHANNEL_SALT ?? "0x0000000000000000000000000000000000000000000000000000000000000000") as `0x${string}`; +const svmChannelSalt = process.env.SVM_CHANNEL_SALT?.trim() || "0"; const numberOfRequests = Number(process.env.NUMBER_OF_REQUESTS ?? "3"); const refundAfterRequests = process.env.REFUND_AFTER_REQUESTS === "true"; const refundAmount = process.env.REFUND_AMOUNT; const depositMultiplier = Number(process.env.DEPOSIT_MULTIPLIER ?? "5"); +const svmRpcUrl = process.env.SVM_RPC_URL; +// Operator keys allowed to sign vouchers on this client's behalf. A +// server-signed channel lets that operator claim up to the whole escrow, so +// nothing here is trusted because a 402 asked for it; the cap below is what a +// dishonest operator could take. +const svmServerSignedOperators = (process.env.SVM_SERVER_SIGNED_OPERATORS ?? "") + .split(",") + .map(key => key.trim()) + .filter(Boolean); +const svmServerSignedMaxDeposit = process.env.SVM_SERVER_SIGNED_MAX_DEPOSIT?.trim() || "$0.05"; + +if (!evmPrivateKeyRaw && !svmPrivateKeyRaw) { + console.error("At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required"); + process.exit(1); +} /** * Runs sequential paid requests against the configured resource server endpoint. @@ -34,40 +48,77 @@ const depositMultiplier = Number(process.env.DEPOSIT_MULTIPLIER ?? "5"); * @returns Resolves after all configured requests complete. */ async function main(): Promise { - const account = privateKeyToAccount(evmPrivateKey); - const publicClient = createPublicClient({ - chain: baseSepolia, - transport: http(), - }); - const signer = toClientEvmSigner(account, publicClient); - - const voucherSigner = evmVoucherSignerPrivateKey - ? toClientEvmSigner(privateKeyToAccount(evmVoucherSignerPrivateKey as `0x${string}`)) - : undefined; - - const batchedScheme = new BatchSettlementEvmScheme(signer, { - depositPolicy: { - depositMultiplier, - }, - salt: channelSalt, - ...(voucherSigner ? { voucherSigner } : {}), - ...(storageDir ? { storage: new FileClientChannelStorage({ directory: storageDir }) } : {}), - }); - - const client = new x402Client(); - client.register("eip155:*", batchedScheme); - // Per-request cap on PaymentRequirements.amount (default "$1" if omitted). - // Deposit ceiling is this cap × depositMultiplier ($5 at the defaults). - client.setSpendControls({ + const client = new x402Client().setSpendControls({ maxAmountPerPayment: "$1", }); + let evmScheme: BatchSettlementEvmScheme | undefined; + let svmScheme: BatchSvmScheme | undefined; + + if (evmPrivateKeyRaw) { + const evmPrivateKey = evmPrivateKeyRaw as `0x${string}`; + const account = privateKeyToAccount(evmPrivateKey); + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + const signer = toClientEvmSigner(account, publicClient); + + const voucherSigner = evmVoucherSignerPrivateKey + ? toClientEvmSigner(privateKeyToAccount(evmVoucherSignerPrivateKey as `0x${string}`)) + : undefined; + + evmScheme = new BatchSettlementEvmScheme(signer, { + depositPolicy: { + depositMultiplier, + }, + salt: channelSalt, + ...(voucherSigner ? { voucherSigner } : {}), + ...(storageDir ? { storage: new FileClientChannelStorage({ directory: storageDir }) } : {}), + }); + client.register("eip155:*", evmScheme); + + console.log("EVM payer:", signer.address); + console.log("EVM payerAuthorizer:", voucherSigner?.address ?? signer.address); + } + + if (svmPrivateKeyRaw) { + const svmSigner = await createKeyPairSignerFromBytes(base58.decode(svmPrivateKeyRaw)); + svmScheme = new BatchSvmScheme(svmSigner, { + depositPolicy: { depositMultiplier }, + salt: svmChannelSalt, + ...(svmRpcUrl ? { rpcUrl: svmRpcUrl } : {}), + ...(svmServerSignedOperators.length > 0 + ? { + serverSignedChannelsPolicy: { + allowedOperators: svmServerSignedOperators, + // USD cap for default assets; list other tokens under + // `allowedAssets` with an atomic `maxDeposit`. + maxDeposit: svmServerSignedMaxDeposit, + }, + } + : {}), + }); + client.register("solana:*", svmScheme); + // Optional: prefer a trusted operator's metered accept over the same + // route's fixed-price accept. Safety does not depend on this; an untrusted + // server-signed accept is refused and the scheme falls back to the + // client-signed accept on its own. + client.registerPolicy(svmScheme.paymentPolicy); + + console.log("SVM payer:", svmSigner.address); + console.log("SVM channel salt:", svmChannelSalt); + console.log( + "SVM server-signed channels:", + svmServerSignedOperators.length > 0 + ? `trusted operators ${svmServerSignedOperators.join(", ")} up to ${svmServerSignedMaxDeposit} per channel` + : "refused (client-signed vouchers only)", + ); + } - const fetchWithPayment = wrapFetchWithPayment(fetch, client); const httpClient = new x402HTTPClient(client); + const fetchWithPayment = wrapFetchWithPayment(fetch, httpClient); - console.log(`Base URL: ${baseURL}, endpoint: ${endpointPath}`); - console.log("payer:", signer.address); - console.log("payerAuthorizer:", voucherSigner?.address ?? signer.address, "\n"); + console.log(`Base URL: ${baseURL}, endpoint: ${endpointPath}\n`); for (let i = 0; i < numberOfRequests; i++) { const requestT0 = performance.now(); @@ -89,17 +140,38 @@ async function main(): Promise { } if (refundAfterRequests) { + if (!evmScheme && !svmScheme) { + console.warn("No batch-settlement scheme is available to refund"); + return; + } + if (refundAmount && !evmScheme) { + throw new Error("SVM batch settlement supports only a full refund"); + } console.log( refundAmount ? `REQUESTING PARTIAL REFUND of ${refundAmount} base units` : "REQUESTING FULL REFUND of remaining channel balance", ); - const refundT0 = performance.now(); - const settle = await batchedScheme.refund(url, { - ...(refundAmount ? { amount: refundAmount } : {}), - }); - console.log(JSON.stringify(settle, null, 2)); - console.log(`Refund completed in ${((performance.now() - refundT0) / 1000).toFixed(3)}s`); + // Each registered scheme refunds its own channel, so a dual-network run + // closes both the EVM and the SVM channel. + if (evmScheme) { + const refundT0 = performance.now(); + const settle = await evmScheme.refund(url, { + ...(refundAmount ? { amount: refundAmount } : {}), + }); + console.log("[EVM]", JSON.stringify(settle, null, 2)); + console.log( + `[EVM] Refund completed in ${((performance.now() - refundT0) / 1000).toFixed(3)}s`, + ); + } + if (svmScheme) { + const refundT0 = performance.now(); + const settle = await svmScheme.refund(url); + console.log("[SVM]", JSON.stringify(settle, null, 2)); + console.log( + `[SVM] Refund completed in ${((performance.now() - refundT0) / 1000).toFixed(3)}s`, + ); + } } } diff --git a/examples/typescript/clients/batch-settlement/package.json b/examples/typescript/clients/batch-settlement/package.json index cc90ef7b77..0daa7eaa40 100644 --- a/examples/typescript/clients/batch-settlement/package.json +++ b/examples/typescript/clients/batch-settlement/package.json @@ -12,8 +12,11 @@ "lint:check": "eslint . --ext .ts" }, "dependencies": { + "@scure/base": "^1.2.6", + "@solana/kit": "^6.1.0", "@x402/evm": "workspace:*", "@x402/fetch": "workspace:*", + "@x402/svm": "workspace:*", "dotenv": "^16.4.7", "viem": "^2.48.11" }, diff --git a/examples/typescript/facilitator/batch-settlement/.env-local b/examples/typescript/facilitator/batch-settlement/.env-local index bf689dec72..81f64b9fa8 100644 --- a/examples/typescript/facilitator/batch-settlement/.env-local +++ b/examples/typescript/facilitator/batch-settlement/.env-local @@ -1,8 +1,17 @@ +# At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required. EVM_PRIVATE_KEY=0x... +SVM_PRIVATE_KEY= + EVM_RPC_URL=https://sepolia.base.org +SVM_RPC_URL= -# Optional dedicated authorizer key (defaults to EVM_PRIVATE_KEY) +# Optional dedicated EVM authorizer key (defaults to unset) EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= +# Rent cleanup (SVM only). Interval between cleanup ticks in seconds. +RENT_CLEANUP_INTERVAL_SECS=30 +# Grace after voucher expiry before abandon-close (default 120). +RENT_CLEANUP_ABANDON_GRACE_SECS=120 + # Optional (default 4022). Same as examples/go/facilitator/batch-settlement. PORT= diff --git a/examples/typescript/facilitator/batch-settlement/index.ts b/examples/typescript/facilitator/batch-settlement/index.ts index bf176f2652..676468edd4 100644 --- a/examples/typescript/facilitator/batch-settlement/index.ts +++ b/examples/typescript/facilitator/batch-settlement/index.ts @@ -1,4 +1,15 @@ +/** + * Batch-settlement facilitator example (EVM + SVM) + * + * Registers the `batch-settlement` scheme on Base Sepolia and/or Solana Devnet. + * For SVM, wires {@link BatchSvmRentCleanupManager} to the scheme's channel + * storage so abandoned channels are sealed and rent is reclaimed asynchronously. + */ + +import { base58 } from "@scure/base"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; import { x402Facilitator } from "@x402/core/facilitator"; +import type { Network } from "@x402/core/types"; import { PaymentPayload, PaymentRequirements, @@ -7,6 +18,15 @@ import { } from "@x402/core/types"; import { type AuthorizerSigner, toFacilitatorEvmSigner } from "@x402/evm"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; +import { toFacilitatorSvmSigner } from "@x402/svm"; +import { + BatchSvmRentCleanupManager, + BatchSvmScheme, + InMemoryBatchChannelStorage, + InMemoryBatchReceiverAuthorizerStore, + type RentCleanupCloseResult, + type RentCleanupReclaimResult, +} from "@x402/svm/batch-settlement/facilitator"; import dotenv from "dotenv"; import express from "express"; import { createWalletClient, http, nonceManager, publicActions } from "viem"; @@ -17,78 +37,36 @@ dotenv.config(); // Configuration const PORT = process.env.PORT || "4022"; +const EVM_NETWORK = "eip155:84532" as Network; +const SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" as Network; + +const evmPrivateKey = process.env.EVM_PRIVATE_KEY?.trim(); +const svmPrivateKey = process.env.SVM_PRIVATE_KEY?.trim(); +const evmRpcUrl = process.env.EVM_RPC_URL ?? "https://sepolia.base.org"; +const svmRpcUrl = process.env.SVM_RPC_URL; // Validate required environment variables -if (!process.env.EVM_PRIVATE_KEY) { - console.error("❌ EVM_PRIVATE_KEY environment variable is required"); +if (!evmPrivateKey && !svmPrivateKey) { + console.error( + "❌ At least one of EVM_PRIVATE_KEY or SVM_PRIVATE_KEY is required", + ); process.exit(1); } -const evmRpcUrl = process.env.EVM_RPC_URL ?? "https://sepolia.base.org"; - // Treat unset or blank as not configured const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim(); -// Initialize the EVM account from private key (submits transactions) -const evmAccount = privateKeyToAccount( - process.env.EVM_PRIVATE_KEY as `0x${string}`, - { nonceManager }, +const rentCleanupIntervalSecs = Number.parseInt( + process.env.RENT_CLEANUP_INTERVAL_SECS ?? "30", + 10, +); +const abandonGraceSecs = Number.parseInt( + process.env.RENT_CLEANUP_ABANDON_GRACE_SECS ?? "120", + 10, ); -// Optional receiverAuthorizer (signs ClaimBatch / Refund EIP-712 messages) -let authorizerSigner: AuthorizerSigner | undefined; -if (receiverAuthorizerPrivateKey) { - const authorizerAccount = privateKeyToAccount( - receiverAuthorizerPrivateKey as `0x${string}`, - ); - authorizerSigner = { - address: authorizerAccount.address, - signTypedData: (params) => - authorizerAccount.signTypedData( - params as Parameters[0], - ), - }; -} - -console.info(`EVM Facilitator account: ${evmAccount.address}`); -if (authorizerSigner) { - console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); -} else { - console.info("EVM Receiver Authorizer: not configured"); -} - -// Create a Viem client with both wallet and public capabilities -const viemClient = createWalletClient({ - account: evmAccount, - chain: baseSepolia, - transport: http(evmRpcUrl), -}).extend(publicActions); - -// Initialize the x402 Facilitator with EVM support -const evmSigner = toFacilitatorEvmSigner({ - address: evmAccount.address, - getCode: (args) => viemClient.getCode(args), - readContract: (args) => - viemClient.readContract({ ...args, args: args.args ?? [] } as Parameters< - typeof viemClient.readContract - >[0]), - verifyTypedData: (args) => - viemClient.verifyTypedData( - args as Parameters[0], - ), - writeContract: (args) => - viemClient.writeContract( - args as Parameters[0], - ), - sendTransaction: (args) => - viemClient.sendTransaction( - args as Parameters[0], - ), - waitForTransactionReceipt: (args) => - viemClient.waitForTransactionReceipt(args), -}); - +const channelStorage = new InMemoryBatchChannelStorage(); const facilitator = new x402Facilitator() .onBeforeVerify(async (context) => { console.log("Before verify", context); @@ -109,11 +87,114 @@ const facilitator = new x402Facilitator() console.log("Settle failure", context); }); -// Register EVM schemes (batched: deposit / voucher / claim / settle) -facilitator.register( - "eip155:84532", - new BatchSettlementEvmScheme(evmSigner, authorizerSigner), -); // Base Sepolia +let rentCleanupManager: BatchSvmRentCleanupManager | undefined; + +if (evmPrivateKey) { + // Initialize the EVM account from private key (submits transactions) + const evmAccount = privateKeyToAccount(evmPrivateKey as `0x${string}`, { + nonceManager, + }); + + // Optional receiverAuthorizer (signs ClaimBatch / Refund EIP-712 messages) + let authorizerSigner: AuthorizerSigner | undefined; + if (receiverAuthorizerPrivateKey) { + const authorizerAccount = privateKeyToAccount( + receiverAuthorizerPrivateKey as `0x${string}`, + ); + authorizerSigner = { + address: authorizerAccount.address, + signTypedData: (params) => + authorizerAccount.signTypedData( + params as Parameters[0], + ), + }; + } + + console.info(`EVM Facilitator account: ${evmAccount.address}`); + if (authorizerSigner) { + console.info(`EVM Receiver Authorizer: ${authorizerSigner.address}`); + } else { + console.info("EVM Receiver Authorizer: not configured"); + } + + // Create a Viem client with both wallet and public capabilities + const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(evmRpcUrl), + }).extend(publicActions); + + const evmSigner = toFacilitatorEvmSigner({ + address: evmAccount.address, + getCode: (args) => viemClient.getCode(args), + readContract: (args) => + viemClient.readContract({ ...args, args: args.args ?? [] } as Parameters< + typeof viemClient.readContract + >[0]), + verifyTypedData: (args) => + viemClient.verifyTypedData( + args as Parameters[0], + ), + writeContract: (args) => + viemClient.writeContract( + args as Parameters[0], + ), + sendTransaction: (args) => + viemClient.sendTransaction( + args as Parameters[0], + ), + waitForTransactionReceipt: (args) => + viemClient.waitForTransactionReceipt(args), + }); + + // Register EVM scheme (batched: deposit / voucher / claim / settle) + facilitator.register( + EVM_NETWORK, + new BatchSettlementEvmScheme(evmSigner, authorizerSigner), + ); // Base Sepolia +} + +if (svmPrivateKey) { + const svmAccount = await createKeyPairSignerFromBytes( + base58.decode(svmPrivateKey), + ); + console.info(`SVM Facilitator account: ${svmAccount.address}`); + + const svmSigner = toFacilitatorSvmSigner( + svmAccount, + svmRpcUrl ? { defaultRpcUrl: svmRpcUrl } : undefined, + ); + const svmBatchScheme = new BatchSvmScheme(svmSigner, { + channelStorage, + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + facilitator.register(SVM_NETWORK, svmBatchScheme); + + rentCleanupManager = svmBatchScheme.createRentCleanupManager(SVM_NETWORK); + rentCleanupManager.start({ + intervalSecs: rentCleanupIntervalSecs, + abandonGraceSecs, + onClose: (result: RentCleanupCloseResult) => { + console.info( + `[rent-cleanup] ${result.action} channel=${result.channelId} tx=${result.transaction}`, + ); + }, + onReclaim: (result: RentCleanupReclaimResult) => { + console.info( + `[rent-cleanup] reclaim channels=${result.channelIds.join(",")} tx=${result.transaction}`, + ); + }, + onError: (error: unknown, context?: { channelId?: string }) => { + console.error("[rent-cleanup] error", { + channelId: context?.channelId, + error: error instanceof Error ? error.message : error, + }); + }, + }); + console.info( + `SVM rent cleanup started (interval=${rentCleanupIntervalSecs}s, abandonGrace=${abandonGraceSecs}s)`, + ); +} // Initialize Express app const app = express(); @@ -207,7 +288,7 @@ app.post("/settle", async (req, res) => { * GET /supported * Get supported payment kinds and extensions */ -app.get("/supported", async (req, res) => { +app.get("/supported", async (_req, res) => { try { const response = facilitator.getSupported(); res.json(response); @@ -219,8 +300,26 @@ app.get("/supported", async (req, res) => { } }); +const enabledNetworks = [ + evmPrivateKey ? "EVM (Base Sepolia)" : null, + svmPrivateKey ? "Solana (devnet)" : null, +] + .filter(Boolean) + .join(", "); + // Start the server app.listen(parseInt(PORT), () => { - console.log(`🚀 Facilitator listening on http://localhost:${PORT}`); + console.log( + `🚀 Batch-settlement facilitator listening on http://localhost:${PORT}`, + ); + console.log(` Networks: ${enabledNetworks}`); console.log(); }); + +async function shutdown(): Promise { + await rentCleanupManager?.stop(); + process.exit(0); +} + +process.on("SIGINT", () => void shutdown()); +process.on("SIGTERM", () => void shutdown()); diff --git a/examples/typescript/facilitator/batch-settlement/package.json b/examples/typescript/facilitator/batch-settlement/package.json index ee95f7a17b..648baada14 100644 --- a/examples/typescript/facilitator/batch-settlement/package.json +++ b/examples/typescript/facilitator/batch-settlement/package.json @@ -12,8 +12,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@scure/base": "^1.2.6", + "@solana/kit": "^6.1.0", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", + "@x402/svm": "workspace:*", "dotenv": "^16.4.5", "express": "^4.19.2", "viem": "^2.48.11" diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index 7d7db7a87a..6a4aaf5bbd 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: version: 1.0.1 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) turbo: specifier: ^2.5.0 version: 2.5.6 @@ -1440,6 +1440,9 @@ importers: '@solana/kit': specifier: '>=5.1.0' version: 5.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/program-client-core': + specifier: ^6.1.0 + version: 6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@x402/core': specifier: workspace:~ version: link:../../core @@ -1845,12 +1848,21 @@ importers: clients/batch-settlement: dependencies: + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + '@solana/kit': + specifier: ^6.1.0 + version: 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) '@x402/evm': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/evm '@x402/fetch': specifier: workspace:* version: link:../../../../typescript/packages/http/fetch + '@x402/svm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/svm dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -2550,12 +2562,21 @@ importers: facilitator/batch-settlement: dependencies: + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + '@solana/kit': + specifier: ^6.1.0 + version: 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) '@x402/core': specifier: workspace:* version: link:../../../../typescript/packages/core '@x402/evm': specifier: workspace:* version: link:../../../../typescript/packages/mechanisms/evm + '@x402/svm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/svm dotenv: specifier: ^16.4.5 version: 16.6.1 @@ -2762,7 +2783,7 @@ importers: version: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) wagmi: specifier: ^2.14.11 - version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + version: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -3051,7 +3072,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3061,6 +3082,12 @@ importers: servers/batch-settlement: dependencies: + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + '@solana/kit': + specifier: ^6.1.0 + version: 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(utf-8-validate@5.0.10) '@x402/core': specifier: workspace:* version: link:../../../../typescript/packages/core @@ -3070,6 +3097,9 @@ importers: '@x402/express': specifier: workspace:* version: link:../../../../typescript/packages/http/express + '@x402/svm': + specifier: workspace:* + version: link:../../../../typescript/packages/mechanisms/svm dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -3112,7 +3142,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3176,7 +3206,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3237,7 +3267,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3399,7 +3429,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3457,7 +3487,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3521,7 +3551,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3707,7 +3737,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3768,7 +3798,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3890,7 +3920,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -3960,7 +3990,7 @@ importers: version: 3.5.2 tsup: specifier: ^8.4.0 - version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + version: 8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) tsx: specifier: ^4.21.0 version: 4.21.0 @@ -7214,9 +7244,6 @@ packages: '@types/node@22.17.2': resolution: {integrity: sha512-gL6z5N9Jm9mhY+U2KXZpteb+09zyffliRkZyZOHODGATyC5B1Jt/7TzuuiLkFsSUMLbS1OLmlj/E+/3KF4Q/4w==} - '@types/node@22.20.1': - resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} - '@types/node@22.20.2': resolution: {integrity: sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==} @@ -13548,7 +13575,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.20.1 + '@types/node': 22.20.2 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -18366,10 +18393,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@22.20.1': - dependencies: - undici-types: 6.21.0 - '@types/node@22.20.2': dependencies: undici-types: 6.21.0 @@ -19006,7 +19029,7 @@ snapshots: - ws - zod - '@wagmi/connectors@6.2.0(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)': + '@wagmi/connectors@6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab)': dependencies: '@base-org/account': 2.4.0(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) '@coinbase/wallet-sdk': 4.3.6(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(zod@4.1.13) @@ -19017,7 +19040,7 @@ snapshots: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) '@walletconnect/ethereum-provider': 2.21.1(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) cbw-sdk: '@coinbase/wallet-sdk@3.9.3' - porto: 0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)) + porto: 0.2.35(6efb213377217713e5f239ae3971a013) viem: 2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) optionalDependencies: typescript: 5.9.3 @@ -20861,7 +20884,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 22.20.1 + '@types/node': 22.20.2 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -20870,7 +20893,7 @@ snapshots: chromium-edge-launcher@0.3.0: dependencies: - '@types/node': 22.20.1 + '@types/node': 22.20.2 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -22806,7 +22829,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 22.20.1 + '@types/node': 22.20.2 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -22823,7 +22846,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 22.20.1 + '@types/node': 22.20.2 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -24043,7 +24066,7 @@ snapshots: - immer - use-sync-external-store - porto@0.2.35(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13)): + porto@0.2.35(6efb213377217713e5f239ae3971a013): dependencies: '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) hono: 4.10.7 @@ -24056,8 +24079,9 @@ snapshots: optionalDependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) react: 19.2.6 + react-native: 0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10) typescript: 5.9.3 - wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + wagmi: 2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) transitivePeerDependencies: - '@types/react' - immer @@ -24067,14 +24091,6 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0): - dependencies: - lilconfig: 3.1.3 - optionalDependencies: - jiti: 2.6.1 - postcss: 8.5.6 - tsx: 4.21.0 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.1): dependencies: lilconfig: 3.1.3 @@ -24163,7 +24179,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 22.20.1 + '@types/node': 22.20.2 long: 5.3.2 protobufjs@8.2.0: @@ -24589,7 +24605,7 @@ snapshots: '@types/uuid': 8.3.4 '@types/ws': 8.18.1 buffer: 6.0.3 - eventemitter3: 5.0.1 + eventemitter3: 5.0.4 uuid: 8.3.2 ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: @@ -25242,34 +25258,6 @@ snapshots: - tsx - yaml - tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3): - dependencies: - bundle-require: 5.1.0(esbuild@0.25.9) - cac: 6.7.14 - chokidar: 4.0.3 - consola: 3.4.2 - debug: 4.4.1 - esbuild: 0.25.9 - fix-dts-default-cjs-exports: 1.0.1 - joycon: 3.1.1 - picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0) - resolve-from: 5.0.0 - rollup: 4.46.4 - source-map: 0.8.0-beta.0 - sucrase: 3.35.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tree-kill: 1.2.2 - optionalDependencies: - postcss: 8.5.6 - typescript: 5.9.3 - transitivePeerDependencies: - - jiti - - supports-color - - tsx - - yaml - tsup@8.5.0(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1): dependencies: bundle-require: 5.1.0(esbuild@0.25.9) @@ -25844,10 +25832,10 @@ snapshots: - ws - zod - wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): + wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react-native@0.85.2(@babel/core@7.28.3)(@types/react@19.2.1)(bufferutil@4.0.9)(react@19.2.6)(utf-8-validate@5.0.10))(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13): dependencies: '@tanstack/react-query': 5.90.11(react@19.2.6) - '@wagmi/connectors': 6.2.0(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(@wagmi/core@2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)))(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(wagmi@2.19.5(@tanstack/query-core@5.90.11)(@tanstack/react-query@5.90.11(react@19.2.6))(@types/react@19.2.1)(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(react@19.2.6)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13))(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(zod@4.1.13) + '@wagmi/connectors': 6.2.0(d27b5019f10ead2ad2c8a66fcf3e72ab) '@wagmi/core': 2.22.1(@tanstack/query-core@5.90.11)(@types/react@19.2.1)(react@19.2.6)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.6))(viem@2.48.11(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13)) react: 19.2.6 use-sync-external-store: 1.4.0(react@19.2.6) diff --git a/examples/typescript/servers/batch-settlement/.env-local b/examples/typescript/servers/batch-settlement/.env-local index e39e3110de..ef7d15e9ab 100644 --- a/examples/typescript/servers/batch-settlement/.env-local +++ b/examples/typescript/servers/batch-settlement/.env-local @@ -1,10 +1,20 @@ +# At least one of EVM_ADDRESS or SVM_ADDRESS is required. EVM_ADDRESS=0x... +SVM_ADDRESS= + FACILITATOR_URL=http://localhost:4022 -# Recommended: self-managed receiver authorizer key. Channels survive facilitator changes. -# Omit to delegate to the facilitator's advertised authorizer (channels are then bound to -# that facilitator until drained). +# EVM: self-managed receiver authorizer key. Omit to delegate to the facilitator. EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= +# SVM: optional receiver authorizer for cooperative close (base58 private key). +SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY= + +# SVM: optional operator key (base58 private key). When set, the route is offered +# server-signed (metered: the handler bills a fraction of the ceiling) next to a +# client-signed accept at the ceiling. Clients only use the server-signed accept for +# operator keys they trust (see the client's SVM_SERVER_SIGNED_OPERATORS). +SVM_OPERATOR_PRIVATE_KEY= + STORAGE_DIR= DEFERRED_WITHDRAW_DELAY_SECONDS=900 diff --git a/examples/typescript/servers/batch-settlement/README.md b/examples/typescript/servers/batch-settlement/README.md index e76fcf72dd..9ab615f637 100644 --- a/examples/typescript/servers/batch-settlement/README.md +++ b/examples/typescript/servers/batch-settlement/README.md @@ -2,7 +2,7 @@ Express server that protects a resource with the **batch-settlement** EVM scheme. Each request is paid by an off-chain voucher; the server batches voucher claims and onchain settlements via a `ChannelManager` running in the background. -The route demonstrates **dynamic pricing**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `setSettlementOverrides`. Every 402 also includes `extra.minDeposit` (SDK default `10 × amount`) so clients can size the channel deposit. Override per route with `accepts.extra.minDeposit` (`"$0.10"` on the default asset, or an atomic string). The server announces the hint only; it does not reject smaller deposits unless you set `enforceMinDeposit: true`. +The route demonstrates **dynamic pricing on EVM**: the client authorizes up to `$0.01` per request, and the handler bills a random fraction of that via `setSettlementOverrides`. Every 402 also includes `extra.minDeposit` (SDK default `10 × amount`) so clients can size the channel deposit. Override per route with `accepts.extra.minDeposit` (`"$0.10"` on the default asset, or an atomic string). The server announces the hint only; it does not reject smaller deposits unless you set `enforceMinDeposit: true`. SVM `batch-settlement` is fixed-price by default, so Solana payments settle at the advertised amount. Set `SVM_OPERATOR_PRIVATE_KEY` to also offer the route **server-signed**: the client signs a per-request proof, the handler meters, and the operator signs the actual cumulative charge. Because that gives the operator authority over the whole channel escrow, the server keeps a client-signed accept on the same route (`extra.voucherSigner: "client"`) and clients use the server-signed one only for origins they have configured as trusted. See the [scheme specification](../../../../specs/schemes/batch-settlement/scheme_batch_settlement_evm.md) and the [scheme README](../../../../typescript/packages/mechanisms/evm/src/batch-settlement/README.md) for protocol details. diff --git a/examples/typescript/servers/batch-settlement/index.ts b/examples/typescript/servers/batch-settlement/index.ts index 50de31fd81..783968122d 100644 --- a/examples/typescript/servers/batch-settlement/index.ts +++ b/examples/typescript/servers/batch-settlement/index.ts @@ -1,4 +1,8 @@ +import { base58 } from "@scure/base"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { decodePaymentSignatureHeader } from "@x402/core/http"; import { HTTPFacilitatorClient } from "@x402/core/server"; +import type { Network } from "@x402/core/types"; import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server"; import { FileChannelStorage } from "@x402/evm/batch-settlement/server/file-storage"; import { @@ -7,23 +11,32 @@ import { x402HTTPResourceServer, x402ResourceServer, } from "@x402/express"; +import type { BatchChannelManager } from "@x402/svm/batch-settlement/server"; +import { BatchSvmScheme, MemoryChannelStore } from "@x402/svm/batch-settlement/server"; import { config } from "dotenv"; import express from "express"; import { privateKeyToAccount } from "viem/accounts"; config(); -const NETWORK = "eip155:84532" as const; +const EVM_NETWORK = "eip155:84532" as Network; +const SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" as Network; -const evmAddress = process.env.EVM_ADDRESS as `0x${string}`; -const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY as +const evmAddress = process.env.EVM_ADDRESS?.trim() as `0x${string}` | undefined; +const svmAddress = process.env.SVM_ADDRESS?.trim(); +const receiverAuthorizerPrivateKey = process.env.EVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim() as | `0x${string}` | undefined; +const svmReceiverAuthorizerPrivateKey = process.env.SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY?.trim(); +// SVM: optional operator key. When set, the SVM route is offered server-signed +// (the operator meters and signs the actual charge) alongside a client-signed +// accept at the ceiling for clients that do not trust this operator. +const svmOperatorPrivateKey = process.env.SVM_OPERATOR_PRIVATE_KEY?.trim(); const storageDir = process.env.STORAGE_DIR; const withdrawDelay = Number(process.env.DEFERRED_WITHDRAW_DELAY_SECONDS ?? "86400"); -if (!evmAddress || !/^0x[0-9a-fA-F]{40}$/.test(evmAddress)) { - console.error("Missing or invalid EVM_ADDRESS (checksummed 20-byte hex, 0x-prefixed)"); +if ((!evmAddress || !/^0x[0-9a-fA-F]{40}$/.test(evmAddress)) && !svmAddress) { + console.error("Missing required EVM_ADDRESS or SVM_ADDRESS environment variable"); process.exit(1); } @@ -38,67 +51,176 @@ const receiverAuthorizerSigner = receiverAuthorizerPrivateKey : undefined; const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +const app = express(); -const batchedScheme = new BatchSettlementEvmScheme(evmAddress, { - ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), - withdrawDelay, - enforceMinDeposit: false, - ...(storageDir ? { storage: new FileChannelStorage({ directory: storageDir }) } : {}), -}); +// Authorize up to this amount per request; optional usage-based override below bills actual usage (EVM only). +const maxPrice = "$0.01"; -const resourceServer = new x402ResourceServer(facilitatorClient).register(NETWORK, batchedScheme); - -const channelManager = batchedScheme.createChannelManager(facilitatorClient, NETWORK); - -channelManager.start({ - claimIntervalSecs: 60, - settleIntervalSecs: 120, - refundIntervalSecs: 180, - maxClaimsPerBatch: 100, - selectRefundChannels: (channels, context) => - channels.filter(channel => { - if (BigInt(channel.balance) === 0n) return false; - if (channel.pendingRequest && channel.pendingRequest.expiresAt > context.now) return false; - return context.now - channel.lastRequestTimestamp >= 180_000; // Refund channels after 3 minutes of inactivity - }), - onClaim: (r: { vouchers: number; transaction: string }) => - console.log(`Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), - onSettle: (r: { transaction: string }) => - console.log(`Settled to ${evmAddress} (tx: ${r.transaction})`), - onRefund: r => console.log(`Refunded channel ${r.channel} (tx: ${r.transaction})`), - onError: (e: unknown) => console.error("Settlement error:", e), -}); +/** + * Initializes facilitator capability checks and starts the batch-settlement server. + */ +async function main() { + let resourceServer = new x402ResourceServer(facilitatorClient); + let channelManager: ReturnType | undefined; + let svmChannelManager: BatchChannelManager | undefined; -process.on("SIGINT", async () => { - console.log("Shutting down — flushing pending claims…"); - await channelManager.stop({ flush: true }); - process.exit(0); -}); + if (evmAddress) { + const batchedEvmScheme = new BatchSettlementEvmScheme(evmAddress, { + ...(receiverAuthorizerSigner ? { receiverAuthorizerSigner } : {}), + withdrawDelay, + enforceMinDeposit: false, + ...(storageDir ? { storage: new FileChannelStorage({ directory: storageDir }) } : {}), + }); + resourceServer = resourceServer.register(EVM_NETWORK, batchedEvmScheme); -const app = express(); + channelManager = batchedEvmScheme.createChannelManager(facilitatorClient, EVM_NETWORK); + channelManager.start({ + claimIntervalSecs: 60, + settleIntervalSecs: 120, + refundIntervalSecs: 180, + maxClaimsPerBatch: 100, + selectRefundChannels: (channels, context) => + channels.filter(channel => { + if (BigInt(channel.balance) === 0n) return false; + if (channel.pendingRequest && channel.pendingRequest.expiresAt > context.now) { + return false; + } + return context.now - channel.lastRequestTimestamp >= 180_000; // Refund channels after 3 minutes of inactivity + }), + onClaim: (r: { vouchers: number; transaction: string }) => + console.log(`[EVM] Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), + onSettle: (r: { transaction: string }) => + console.log(`[EVM] Settled to ${evmAddress} (tx: ${r.transaction})`), + onRefund: r => console.log(`[EVM] Refunded channel ${r.channel} (tx: ${r.transaction})`), + onError: (e: unknown) => console.error("[EVM] Settlement error:", e), + }); + } -// Authorize up to this amount per request; optional usage-based override below bills actual usage. -const maxPrice = "$0.01"; + const svmOperatorSigner = svmOperatorPrivateKey + ? await createKeyPairSignerFromBytes(base58.decode(svmOperatorPrivateKey)) + : undefined; + + let svmReceiverAuthorizerSigner: + | Awaited> + | undefined; + + if (svmAddress) { + if (!svmReceiverAuthorizerPrivateKey) { + console.error("Missing required SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY environment variable"); + process.exit(1); + } + svmReceiverAuthorizerSigner = await createKeyPairSignerFromBytes( + base58.decode(svmReceiverAuthorizerPrivateKey), + ); + const batchedSvmScheme = new BatchSvmScheme({ + withdrawDelay, + // Advertised as extra.receiverAuthorizer; signs cooperative refunds and + // seals of channels the payer is closing. + receiverAuthorizer: svmReceiverAuthorizerSigner, + ...(svmOperatorSigner ? { operator: svmOperatorSigner } : {}), + store: new MemoryChannelStore(), + }); + resourceServer = resourceServer.register(SVM_NETWORK, batchedSvmScheme); + + // The redemption worker claims accumulated vouchers and distributes what + // they settle. It redeems against the same terms the 402 advertises, so + // build them the way the resource server does: route requirements plus + // the facilitator's /supported kind (which carries feePayer and the idle + // window maxIdleSecs the server must claim inside). + const supported = await facilitatorClient.getSupported(); + const svmKind = supported.kinds.find( + kind => kind.scheme === "batch-settlement" && kind.network === SVM_NETWORK, + ); + if (svmKind) { + const svmRequirements = await batchedSvmScheme.enhancePaymentRequirements( + { + scheme: "batch-settlement", + network: SVM_NETWORK, + // $0.01 in USDC atomic units: the worker only reads network, asset, + // payTo and extra.feePayer from these terms. + amount: "10000", + asset: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + payTo: svmAddress, + maxTimeoutSeconds: 300, + extra: {}, + }, + svmKind, + [], + ); + svmChannelManager = batchedSvmScheme.createChannelManager( + facilitatorClient, + svmRequirements, + { + onClaim: (r: { vouchers: number; transaction: string }) => + console.log(`[SVM] Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), + onSettle: (r: { transaction: string }) => + console.log(`[SVM] Settled to ${svmAddress} (tx: ${r.transaction})`), + onSeal: (r: { channel: string; transaction: string }) => + console.log(`[SVM] Sealed channel ${r.channel} (tx: ${r.transaction})`), + onError: (e: unknown) => console.error("[SVM] Settlement error:", e), + }, + ); + const svmRedemptionIntervalSecs = 60; + // Well inside the facilitator's idle window (default seven days). + svmChannelManager.start(svmRedemptionIntervalSecs); + console.log( + `[SVM] Redeeming every ${svmRedemptionIntervalSecs}s; facilitator idle window: ${String(svmKind.extra?.maxIdleSecs ?? "none")}s`, + ); + } else { + console.warn( + "[SVM] facilitator does not advertise batch-settlement; no redemption worker started", + ); + } + } -const httpServer = new x402HTTPResourceServer(resourceServer, { - "GET /weather": { - accepts: { + if (channelManager || svmChannelManager) { + process.on("SIGINT", async () => { + console.log("Shutting down — flushing pending claims…"); + await channelManager?.stop({ flush: true }); + await svmChannelManager?.stop({ flush: true }); + process.exit(0); + }); + } + + const accepts = []; + if (evmAddress) { + accepts.push({ scheme: "batch-settlement", price: maxPrice, - network: NETWORK, + network: EVM_NETWORK, payTo: evmAddress, - // Optional: override the default 10× deposit hint (Money strings require the network default asset). - // extra: { minDeposit: "$0.10" }, + }); + } + if (svmAddress) { + // With an operator configured this accept is server-signed: the client + // signs a per-request proof and the operator signs the metered charge. + accepts.push({ + scheme: "batch-settlement", + price: maxPrice, + network: SVM_NETWORK, + payTo: svmAddress, + }); + if (svmOperatorSigner) { + // Clients that have not trusted this operator drop the server-signed + // accept and pay the ceiling with their own vouchers instead. + accepts.push({ + scheme: "batch-settlement", + price: maxPrice, + network: SVM_NETWORK, + payTo: svmAddress, + extra: { voucherSigner: "client" }, + }); + } + } + + const httpServer = new x402HTTPResourceServer(resourceServer, { + "GET /weather": { + accepts, + description: "Weather data", + mimeType: "application/json", }, - description: "Weather data", - mimeType: "application/json", - }, -}); + }); -/** - * Initializes facilitator capability checks and starts the batch-settlement server. - */ -async function main() { // Fail fast on misconfiguration: this throws the capability error (and any // HTTP route validation error) before the server starts accepting requests. await httpServer.initialize(); @@ -106,8 +228,18 @@ async function main() { app.use(paymentMiddlewareFromHTTPServer(httpServer, undefined, undefined, false)); app.get("/weather", (req, res) => { - const chargedPercent = 1 + Math.floor(Math.random() * 100); - setSettlementOverrides(res, { amount: `${chargedPercent}%` }); + const paymentHeader = req.header("payment-signature") ?? req.header("x-payment"); + if (paymentHeader) { + const { accepted } = decodePaymentSignatureHeader(paymentHeader); + // EVM batch-settlement supports charging less than the authorized max, + // and so does an SVM server-signed channel (the operator signs the + // actual charge). A client-signed SVM channel is fixed-price: the + // voucher increment must equal PaymentRequirements.amount. + if (accepted.network.startsWith("eip155:") || accepted.extra?.voucherSigner === "server") { + const chargedPercent = 1 + Math.floor(Math.random() * 100); + setSettlementOverrides(res, { amount: `${chargedPercent}%` }); + } + } res.send({ report: { @@ -117,13 +249,38 @@ async function main() { }); }); + const enabledNetworks = [ + evmAddress ? "EVM (Base Sepolia)" : null, + svmAddress ? "Solana (devnet)" : null, + ] + .filter(Boolean) + .join(", "); + app.listen(4021, () => { console.log("Batch-settlement server listening at http://localhost:4021"); - console.log(" GET /weather"); - if (receiverAuthorizerSigner) { - console.log(` Receiver authorizer: local signer ${receiverAuthorizerSigner.address}`); - } else { - console.log(" Receiver authorizer: facilitator"); + console.log(` GET /weather (${enabledNetworks})`); + if (evmAddress) { + if (receiverAuthorizerSigner) { + console.log(` EVM receiver authorizer: local signer ${receiverAuthorizerSigner.address}`); + } else { + console.log(" EVM receiver authorizer: facilitator"); + } + } + if (svmAddress) { + if (svmReceiverAuthorizerSigner) { + console.log( + ` SVM receiver authorizer: local signer ${svmReceiverAuthorizerSigner.address}`, + ); + } else { + console.log(" SVM receiver authorizer: facilitator"); + } + } + if (svmAddress) { + console.log( + svmOperatorSigner + ? ` SVM vouchers: server-signed by operator ${svmOperatorSigner.address} (client-signed accept offered alongside)` + : " SVM vouchers: client-signed", + ); } }); } diff --git a/examples/typescript/servers/batch-settlement/package.json b/examples/typescript/servers/batch-settlement/package.json index 86d4398ece..7aa4ac09a3 100644 --- a/examples/typescript/servers/batch-settlement/package.json +++ b/examples/typescript/servers/batch-settlement/package.json @@ -10,9 +10,12 @@ "lint:check": "eslint . --ext .ts" }, "dependencies": { + "@scure/base": "^1.2.6", + "@solana/kit": "^6.1.0", "@x402/core": "workspace:*", "@x402/evm": "workspace:*", "@x402/express": "workspace:*", + "@x402/svm": "workspace:*", "dotenv": "^16.4.7", "express": "^4.18.2", "viem": "^2.48.11" diff --git a/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml b/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml deleted file mode 100644 index 9ed0256cd6..0000000000 --- a/go/.changes/unreleased/added-20260908-batch-settlement-mindeposit.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: added -body: Pass the resolved atomic spendControls cap to every scheme on PaymentPayloadContext.MaxAmountPerPayment (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce extra.minDeposit (default 10 × amount, optional per-route override via accepts.extra.minDeposit). Clients size deposits from the hint when valid, clamped to that cap × DepositMultiplier when a spend cap is set. Uncapped payments leave deposits uncapped. Older 402s fall back to DepositMultiplier for sizing. Servers may opt in to SDK enforcement via EnforceMinDeposit (default off; facilitator never enforces). -time: 2026-09-08T09:00:00Z diff --git a/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml b/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml deleted file mode 100644 index 288ceccd1b..0000000000 --- a/go/.changes/unreleased/changed-20260915-unify-payload-context.yaml +++ /dev/null @@ -1,3 +0,0 @@ -kind: changed -body: Pass PaymentPayloadContext (server extensions and resolved spend cap) on SchemeNetworkClient and SchemeNetworkClientV1 CreatePaymentPayload. Remove the optional CreatePaymentPayloadWithExtensions / ExtensionAwareClient split so every scheme uses one payload API. -time: 2026-09-15T09:00:00Z diff --git a/go/.changes/v2.27.0.md b/go/.changes/v2.27.0.md new file mode 100644 index 0000000000..5628e954a4 --- /dev/null +++ b/go/.changes/v2.27.0.md @@ -0,0 +1,7 @@ +## v2.27.0 - 2026-09-22 +### Added +- Pass the resolved atomic spendControls cap to every scheme on PaymentPayloadContext.MaxAmountPerPayment (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce extra.minDeposit (default 10 × amount, optional per-route override via accepts.extra.minDeposit). Clients size deposits from the hint when valid, clamped to that cap × DepositMultiplier when a spend cap is set. Uncapped payments leave deposits uncapped. Older 402s fall back to DepositMultiplier for sizing. Servers may opt in to SDK enforcement via EnforceMinDeposit (default off; facilitator never enforces). ([#3410](https://github.com/x402-foundation/x402/pull/3410)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +### Changed +- Pass PaymentPayloadContext (server extensions and resolved spend cap) on SchemeNetworkClient and SchemeNetworkClientV1 CreatePaymentPayload. Remove the optional CreatePaymentPayloadWithExtensions / ExtensionAwareClient split so every scheme uses one payload API. ([#3410](https://github.com/x402-foundation/x402/pull/3410)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +### Fixed +- HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view (net/url URL.Path), requiring payment if either matches. A literal route such as GET /api/premium could previously be reached unpaid by encoding its path separator (/api%2Fpremium) when the adapter only consulted EscapedPath() while Gin, Echo, and net/http dispatched on the decoded path. ([#3543](https://github.com/x402-foundation/x402/pull/3543)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! \ No newline at end of file diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md index 6521bd65a5..bf252690db 100644 --- a/go/CHANGELOG.md +++ b/go/CHANGELOG.md @@ -1,3 +1,11 @@ +## v2.27.0 - 2026-09-22 +### Added +- Pass the resolved atomic spendControls cap to every scheme on PaymentPayloadContext.MaxAmountPerPayment (omitted when uncapped) so capital-locking schemes can reuse client policy without re-resolving it. Batch-settlement EVM servers always announce extra.minDeposit (default 10 × amount, optional per-route override via accepts.extra.minDeposit). Clients size deposits from the hint when valid, clamped to that cap × DepositMultiplier when a spend cap is set. Uncapped payments leave deposits uncapped. Older 402s fall back to DepositMultiplier for sizing. Servers may opt in to SDK enforcement via EnforceMinDeposit (default off; facilitator never enforces). ([#3410](https://github.com/x402-foundation/x402/pull/3410)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +### Changed +- Pass PaymentPayloadContext (server extensions and resolved spend cap) on SchemeNetworkClient and SchemeNetworkClientV1 CreatePaymentPayload. Remove the optional CreatePaymentPayloadWithExtensions / ExtensionAwareClient split so every scheme uses one payload API. ([#3410](https://github.com/x402-foundation/x402/pull/3410)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +### Fixed +- HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view (net/url URL.Path), requiring payment if either matches. A literal route such as GET /api/premium could previously be reached unpaid by encoding its path separator (/api%2Fpremium) when the adapter only consulted EscapedPath() while Gin, Echo, and net/http dispatched on the decoded path. ([#3543](https://github.com/x402-foundation/x402/pull/3543)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## v2.26.0 - 2026-09-15 ### Added - Add Celo mainnet USDT and USAT (EIP-3009) as default assets for eip155:42220 so "$0.10 USDT" and "$0.10 USAT" resolve on Celo; bare "$0.10" still resolves to USDC. ([#3457](https://github.com/x402-foundation/x402/pull/3457)) - Thanks [@GigaHierz](https://github.com/GigaHierz)! diff --git a/go/constants.go b/go/constants.go index 7404494e91..077f10e8b9 100644 --- a/go/constants.go +++ b/go/constants.go @@ -3,7 +3,7 @@ package x402 // Version constants const ( // Version is the SDK version - Version = "2.26.0" + Version = "2.27.0" // ProtocolVersion is the current x402 protocol version ProtocolVersion = 2 diff --git a/go/http/echo/middleware.go b/go/http/echo/middleware.go index 86e760e735..7255a6a376 100644 --- a/go/http/echo/middleware.go +++ b/go/http/echo/middleware.go @@ -294,11 +294,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewEchoAdapter(c) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: c.Request().URL.EscapedPath(), - Method: c.Request().Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: c.Request().URL.EscapedPath(), + DecodedPath: c.Request().URL.Path, + Method: c.Request().Method, } // Check if route requires payment before waiting for initialization diff --git a/go/http/echo/middleware_test.go b/go/http/echo/middleware_test.go index 62937c5167..1d69a0f39f 100644 --- a/go/http/echo/middleware_test.go +++ b/go/http/echo/middleware_test.go @@ -1565,3 +1565,98 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: when EscapedPath and URL.Path diverge, payment +// matching must consult both so a decoded-path dispatcher cannot fail-open +// a literal route such as GET /api/premium via /api%2Fpremium. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + e := createTestEcho() + e.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + + handlerRan := false + e.GET("/api/premium", func(c echo.Context) error { + handlerRan = true + return c.JSON(http.StatusOK, map[string]string{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + e.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + e := createTestEcho() + e.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + e.GET("/api/premium", func(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + e.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/gin/middleware.go b/go/http/gin/middleware.go index 18e9d30bbd..c7761d4643 100644 --- a/go/http/gin/middleware.go +++ b/go/http/gin/middleware.go @@ -295,11 +295,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewGinAdapter(c) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: c.Request.URL.EscapedPath(), - Method: c.Request.Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: c.Request.URL.EscapedPath(), + DecodedPath: c.Request.URL.Path, + Method: c.Request.Method, } // Check if route requires payment before waiting for initialization diff --git a/go/http/gin/middleware_test.go b/go/http/gin/middleware_test.go index e22d9103e8..2072f8ba68 100644 --- a/go/http/gin/middleware_test.go +++ b/go/http/gin/middleware_test.go @@ -1827,3 +1827,99 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: Gin's default router (UseRawPath=false) dispatches +// literal routes on the decoded path. A request for /api%2Fpremium therefore +// reaches GET /api/premium, while matching only EscapedPath() misses the +// literal pattern and fail-opens. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + router := createTestRouter() + router.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + + handlerRan := false + router.GET("/api/premium", func(c *gin.Context) { + handlerRan = true + c.JSON(http.StatusOK, gin.H{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{ + supportedFunc: func(ctx context.Context) (x402.SupportedResponse, error) { + return x402.SupportedResponse{ + Kinds: []x402.SupportedKind{ + {X402Version: 2, Scheme: "exact", Network: "eip155:1"}, + }, + Extensions: []string{}, + Signers: make(map[string][]string), + }, nil + }, + } + + router := createTestRouter() + router.Use(PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + )) + router.GET("/api/premium", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"secret": "paid content"}) + }) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/nethttp/middleware.go b/go/http/nethttp/middleware.go index 913893580d..e36e2e53d7 100644 --- a/go/http/nethttp/middleware.go +++ b/go/http/nethttp/middleware.go @@ -228,11 +228,11 @@ func createMiddlewareHandler(server *x402http.HTTPServer, config *MiddlewareConf adapter := NewNetHTTPAdapter(r) reqCtx := x402http.HTTPRequestContext{ Adapter: adapter, - // EscapedPath, not Path: routers dispatch on the escaped path, so - // matching on the decoded one lets "%2F" split a segment here but - // not in the router, bypassing the payment gate. - Path: r.URL.EscapedPath(), - Method: r.Method, + // Match both EscapedPath and URL.Path (the decoded routing view) + // so a route can't be bypassed via either representation. + Path: r.URL.EscapedPath(), + DecodedPath: r.URL.Path, + Method: r.Method, } // Check if route requires payment diff --git a/go/http/nethttp/middleware_test.go b/go/http/nethttp/middleware_test.go index efae68211e..12b5e819b8 100644 --- a/go/http/nethttp/middleware_test.go +++ b/go/http/nethttp/middleware_test.go @@ -1619,3 +1619,82 @@ func TestPaymentMiddleware_EncodedPathDoesNotBypassPaymentGate(t *testing.T) { }) } } + +// TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass guards the +// complementary CWE-436: when EscapedPath and URL.Path diverge, payment +// matching must consult both so a decoded-path dispatcher cannot fail-open +// a literal route such as GET /api/premium via /api%2Fpremium. +func TestPaymentMiddleware_LiteralRoutePercentEncodedSeparatorBypass(t *testing.T) { + bypassPaths := []string{ + "/api/premium", // baseline: plainly protected + "/api%2Fpremium", // encoded slash + "/api%2fpremium", // lowercase encoded slash + "/%61pi%2Fpremium", // encoded slash and letter + } + + routes := x402http.RoutesConfig{ + "GET /api/premium": x402http.RouteConfig{ + Accepts: x402http.PaymentOptions{ + {Scheme: "exact", PayTo: "0xtest", Price: "$1.00", Network: "eip155:1"}, + }, + }, + } + + for _, path := range bypassPaths { + t.Run(path, func(t *testing.T) { + mockClient := &mockFacilitatorClient{supportedFunc: defaultSupportedFunc()} + + handlerRan := false + paidHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerRan = true + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"secret": "paid content"}) + }) + + mux := http.NewServeMux() + mux.Handle("GET /api/premium", paidHandler) + + middleware := PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + ) + wrapped := middleware(mux) + + req := httptest.NewRequest("GET", path, nil) + req.Header.Set("Accept", "application/json") + w := httptest.NewRecorder() + wrapped.ServeHTTP(w, req) + + if handlerRan { + t.Errorf("payment bypassed: paid handler ran for %s (status %d)", path, w.Code) + } + if w.Code != http.StatusPaymentRequired { + t.Errorf("Expected status 402 for %s, got %d", path, w.Code) + } + }) + } + + t.Run("/health", func(t *testing.T) { + mockClient := &mockFacilitatorClient{supportedFunc: defaultSupportedFunc()} + mux := http.NewServeMux() + mux.Handle("GET /api/premium", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + middleware := PaymentMiddlewareFromConfig(routes, + WithFacilitatorClient(mockClient), + WithScheme("eip155:1", &mockSchemeServer{scheme: "exact"}), + WithSyncFacilitatorOnStart(true), + WithTimeout(5*time.Second), + ) + wrapped := middleware(mux) + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + wrapped.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404 for /health, got %d", w.Code) + } + }) +} diff --git a/go/http/server.go b/go/http/server.go index f510b00a0f..b83bf2e1a7 100644 --- a/go/http/server.go +++ b/go/http/server.go @@ -172,7 +172,10 @@ type HTTPRequestContext struct { Method string PaymentHeader string RoutePattern string - Requirements []types.PaymentRequirements + // DecodedPath is the framework's own decoded routing view of the path + // (e.g. net/url's URL.Path), if distinct from Path. + DecodedPath string + Requirements []types.PaymentRequirements } // HTTPTransportContext carries request and response data through settlement processing. @@ -564,7 +567,7 @@ func (s *x402HTTPResourceServer) ProcessHTTPRequest(ctx context.Context, reqCtx } // Find matching route - routeConfig, routePattern := s.getRouteConfig(reqCtx.Path, reqCtx.Method) + routeConfig, routePattern := s.getRouteConfig(reqCtx.Path, reqCtx.Method, reqCtx.DecodedPath) if routeConfig == nil { return HTTPProcessResult{Type: ResultNoPaymentRequired} } @@ -884,7 +887,7 @@ func (s *x402HTTPResourceServer) RequiresPayment(reqCtx HTTPRequestContext) bool if method == "" { method = reqCtx.Adapter.GetMethod() } - routeConfig, _ := s.getRouteConfig(reqCtx.Path, method) + routeConfig, _ := s.getRouteConfig(reqCtx.Path, method, reqCtx.DecodedPath) return routeConfig != nil } @@ -1192,17 +1195,30 @@ func (s *x402HTTPResourceServer) buildSettlementFailureResult(errorReason string // Helper Methods // ============================================================================ -// getRouteConfig finds matching route configuration and returns the route pattern -func (s *x402HTTPResourceServer) getRouteConfig(path, method string) (*RouteConfig, string) { - normalizedPath := normalizePath(path) +// getRouteConfig finds matching route configuration and returns the route pattern. +// +// Checks the escaped path first, then the framework's decodedPath (if distinct), +// so a route can't be bypassed via either representation. +func (s *x402HTTPResourceServer) getRouteConfig(path, method, decodedPath string) (*RouteConfig, string) { upperMethod := strings.ToUpper(method) - for _, route := range s.compiledRoutes { - if route.Regex.MatchString(normalizedPath) && - (route.Verb == "*" || route.Verb == upperMethod) { - config := route.Config // Make a copy - return &config, route.Pattern + findMatch := func(candidate string) (*RouteConfig, string) { + for _, route := range s.compiledRoutes { + if route.Regex.MatchString(candidate) && + (route.Verb == "*" || route.Verb == upperMethod) { + config := route.Config // Make a copy + return &config, route.Pattern + } } + return nil, "" + } + + if config, pattern := findMatch(normalizePath(path)); config != nil { + return config, pattern + } + + if decodedPath != "" && decodedPath != path { + return findMatch(normalizeDecodedPath(decodedPath)) } return nil, "" @@ -1610,3 +1626,21 @@ func normalizePath(path string) string { return path } + +// normalizeDecodedPath normalizes an already framework-decoded path. It does +// not decode percent-escapes, unlike normalizePath, since this input was +// already decoded once by the router. +func normalizeDecodedPath(path string) string { + if idx := strings.IndexAny(path, "?#"); idx >= 0 { + path = path[:idx] + } + + path = multiSlashRegex.ReplaceAllString(path, `/`) + path = strings.TrimSuffix(path, `/`) + + if path == "" { + path = "/" + } + + return path +} diff --git a/go/http/server_test.go b/go/http/server_test.go index aac6d4ac28..f2a190cb63 100644 --- a/go/http/server_test.go +++ b/go/http/server_test.go @@ -1250,6 +1250,84 @@ func TestRouteMatching_PathNormalizationBypass(t *testing.T) { } } +func TestNormalizeDecodedPath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"/api", "/api"}, + {"/api/", "/api"}, + {"/api//users", "/api/users"}, + {"/api?query=1", "/api"}, + {"/api#fragment", "/api"}, + {"", "/"}, + // Already-decoded input is passed through, not re-decoded. + {"/api/x%41", "/api/x%41"}, + {"/api/premium", "/api/premium"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result := normalizeDecodedPath(tt.input) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +func TestDecodedPathDivergenceBypass(t *testing.T) { + serverFor := func(pattern string) *x402HTTPResourceServer { + return Newx402HTTPResourceServer(RoutesConfig{ + pattern: {Accepts: PaymentOptions{}}, + }) + } + + t.Run("literal route requires payment when decoded path matches", func(t *testing.T) { + escapedPaths := []string{"/api%2Fpremium", "/api%2fpremium", "/%61pi%2Fpremium"} + for _, escapedPath := range escapedPaths { + reqCtx := HTTPRequestContext{ + Path: escapedPath, + Method: "GET", + DecodedPath: "/api/premium", + } + if !serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Errorf("expected payment required for escaped path %s", escapedPath) + } + } + }) + + t.Run("literal route misses without decoded path", func(t *testing.T) { + // Pre-fix behavior: only the escaped path is checked. + reqCtx := HTTPRequestContext{Path: "/api%2Fpremium", Method: "GET"} + if serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Error("expected no payment required when decoded path is absent") + } + }) + + t.Run("real extra segment still not matched", func(t *testing.T) { + reqCtx := HTTPRequestContext{ + Path: "/api/users/x/y", + Method: "GET", + DecodedPath: "/api/users/x/y", + } + if serverFor("GET /api/users/:id").RequiresPayment(reqCtx) { + t.Error("expected extra segment not to match :id route") + } + }) + + t.Run("unrelated decoded path does not require payment", func(t *testing.T) { + reqCtx := HTTPRequestContext{ + Path: "/public/report", + Method: "GET", + DecodedPath: "/public/report", + } + if serverFor("GET /api/premium").RequiresPayment(reqCtx) { + t.Error("expected unrelated path not to require payment") + } + }) +} + func TestGetDisplayAmount(t *testing.T) { server := Newx402HTTPResourceServer(RoutesConfig{}) diff --git a/python/x402/CHANGELOG.md b/python/x402/CHANGELOG.md index ddfa7af341..acd74300e2 100644 --- a/python/x402/CHANGELOG.md +++ b/python/x402/CHANGELOG.md @@ -2,6 +2,15 @@ +## [2.24.0] - 2026-09-22 + +### Fixed + +- FastAPI and Flask payment middleware now also match routes against each framework's decoded request path (Starlette's `request.url.path`, Werkzeug's `PATH_INFO`) in addition to the escaped path, requiring payment if either matches. A literal protected route (e.g. `GET /api/premium`) could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`), since the escaped-path check alone did not match while the framework still decoded and dispatched to the paid handler. The FastAPI decoded-path check now also strips the ASGI `root_path` mount prefix (as Starlette's own router does internally) so apps mounted under a non-empty `root_path` remain protected instead of always fail-opening. ([#3502](https://github.com/x402-foundation/x402/pull/3502)) - Thanks [@CarsonRoscoe](https://github.com/CarsonRoscoe) and [@cursoragent](https://github.com/cursoragent), [@claude](https://github.com/claude)! +- FastMCP paid-tool 402s now expose the protocol InvalidReason instead of a prefixed verify error string. ([#3494](https://github.com/x402-foundation/x402/pull/3494)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! +- MCP 402 responses snapshot payment accepts before scheme enrichers so batch-settlement recovery retries still match. ([#3495](https://github.com/x402-foundation/x402/pull/3495)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + + ## [2.23.0] - 2026-09-15 ### Fixed diff --git a/python/x402/__init__.py b/python/x402/__init__.py index 9ffc467bb4..979a830c82 100644 --- a/python/x402/__init__.py +++ b/python/x402/__init__.py @@ -173,7 +173,7 @@ x402ResourceServerSync, ) -__version__ = "2.23.0" +__version__ = "2.24.0" __all__ = [ # Version diff --git a/python/x402/changelog.d/3494.bugfix.md b/python/x402/changelog.d/3494.bugfix.md deleted file mode 100644 index 46bd420777..0000000000 --- a/python/x402/changelog.d/3494.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -FastMCP paid-tool 402s now expose the protocol InvalidReason instead of a prefixed verify error string. diff --git a/python/x402/changelog.d/3495.bugfix.md b/python/x402/changelog.d/3495.bugfix.md deleted file mode 100644 index 8b1b588f34..0000000000 --- a/python/x402/changelog.d/3495.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -MCP 402 responses snapshot payment accepts before scheme enrichers so batch-settlement recovery retries still match. diff --git a/python/x402/changelog.d/3502.bugfix.md b/python/x402/changelog.d/3502.bugfix.md deleted file mode 100644 index ffa4fbff30..0000000000 --- a/python/x402/changelog.d/3502.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -FastAPI and Flask payment middleware now also match routes against each framework's decoded request path (Starlette's `request.url.path`, Werkzeug's `PATH_INFO`) in addition to the escaped path, requiring payment if either matches. A literal protected route (e.g. `GET /api/premium`) could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`), since the escaped-path check alone did not match while the framework still decoded and dispatched to the paid handler. The FastAPI decoded-path check now also strips the ASGI `root_path` mount prefix (as Starlette's own router does internally) so apps mounted under a non-empty `root_path` remain protected instead of always fail-opening. diff --git a/python/x402/changelog.d/3573.bugfix.md b/python/x402/changelog.d/3573.bugfix.md new file mode 100644 index 0000000000..b2b80d6f7d --- /dev/null +++ b/python/x402/changelog.d/3573.bugfix.md @@ -0,0 +1 @@ +Cap the `svm` extra at `solana<0.40`. `solana` 0.40.0 removed the synchronous `solana.rpc.api` module that the SVM signers, clients and server import, so a fresh `pip install x402[svm]` resolved to 0.40.x and `from x402.mechanisms.svm import KeypairSigner` failed with a misleading "requires solana packages" error. diff --git a/python/x402/pyproject.toml b/python/x402/pyproject.toml index 50a1d5e29a..e7cf375a54 100644 --- a/python/x402/pyproject.toml +++ b/python/x402/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "x402" -version = "2.23.0" +version = "2.24.0" description = "x402 Payment Protocol SDK for Python" readme = "README.md" license = { text = "MIT" } @@ -44,7 +44,7 @@ evm = [ ] svm = [ "solders>=0.27.0", - "solana>=0.36.0", + "solana>=0.36.0,<0.40", ] tvm = [ "httpx>=0.28.1", @@ -92,7 +92,7 @@ dev = [ "web3>=7.0.0", # SVM dependencies "solders>=0.27.0", - "solana>=0.36.0", + "solana>=0.36.0,<0.40", # TVM dependencies "pytoniq>=0.1.39", "pytoniq-core>=0.1.36", diff --git a/python/x402/uv.lock b/python/x402/uv.lock index f37071eec1..bed7f4b6b2 100644 --- a/python/x402/uv.lock +++ b/python/x402/uv.lock @@ -3871,7 +3871,7 @@ wheels = [ [[package]] name = "x402" -version = "2.23.0" +version = "2.24.0" source = { editable = "." } dependencies = [ { name = "nest-asyncio" }, @@ -4014,7 +4014,7 @@ requires-dist = [ { name = "pytoniq-core", marker = "extra == 'tvm'", specifier = ">=0.1.36" }, { name = "requests", marker = "extra == 'requests'", specifier = ">=2.31.0" }, { name = "signinwithethereum", marker = "extra == 'extensions'", specifier = ">=5.0.1" }, - { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0" }, + { name = "solana", marker = "extra == 'svm'", specifier = ">=0.36.0,<0.40" }, { name = "solders", marker = "extra == 'svm'", specifier = ">=0.27.0" }, { name = "starlette", marker = "extra == 'fastapi'", specifier = ">=0.27.0" }, { name = "typing-extensions", specifier = ">=4.0.0" }, @@ -4049,7 +4049,7 @@ dev = [ { name = "requests", specifier = ">=2.31.0" }, { name = "ruff", specifier = ">=0.1.0" }, { name = "signinwithethereum", specifier = "==5.0.1" }, - { name = "solana", specifier = ">=0.36.0" }, + { name = "solana", specifier = ">=0.36.0,<0.40" }, { name = "solders", specifier = ">=0.27.0" }, { name = "starlette", specifier = ">=0.27.0" }, { name = "towncrier", specifier = ">=24.8.0,<25" }, diff --git a/scripts/payment-channels-codegen/generate-payment-channels-client.ts b/scripts/payment-channels-codegen/generate-payment-channels-client.ts new file mode 100644 index 0000000000..4fd15db7a1 --- /dev/null +++ b/scripts/payment-channels-codegen/generate-payment-channels-client.ts @@ -0,0 +1,176 @@ +/** Generate the vendored payment-channels TypeScript client from the pinned IDL. */ + +import type { AnchorIdl } from "@codama/nodes-from-anchor"; +import { renderVisitor } from "@codama/renderers-js"; +import { createFromJson } from "codama"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { format, resolveConfig } from "prettier"; + +const repositoryDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const packageDir = path.join( + repositoryDir, + "typescript", + "packages", + "mechanisms", + "svm", +); +const idlPath = path.join(packageDir, "idl", "payment-channels.json"); +const outputDir = path.join(packageDir, "src", "payment-channels", "generated"); + +type CodamaEnum = { + name: string; + type: { + kind: "enumTypeNode"; + variants: { discriminator?: number; name: string }[]; + }; +}; + +type CodamaRoot = { + program: { + definedTypes: ({ name: string; type: { kind: string } } | CodamaEnum)[]; + publicKey: string; + }; +}; + +if (!fs.existsSync(idlPath)) { + throw new Error(`payment-channels IDL not found at ${idlPath}`); +} + +const idl = JSON.parse(fs.readFileSync(idlPath, "utf8")) as AnchorIdl; +const codama = createFromJson(JSON.stringify(idl)); +const temporaryPackage = fs.mkdtempSync( + path.join(os.tmpdir(), "x402-payment-channels-"), +); + +const formatGeneratedTree = async (directory: string): Promise => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const filename = path.join(directory, entry.name); + if (entry.isDirectory()) { + await formatGeneratedTree(filename); + } else if (entry.name.endsWith(".ts")) { + const config = (await resolveConfig(filename)) ?? {}; + const source = fs.readFileSync(filename, "utf8"); + fs.writeFileSync( + filename, + await format(source, { ...config, filepath: filename }), + ); + } + } +}; + +const upperFirst = (value: string): string => + value.charAt(0).toUpperCase() + value.slice(1); + +/** Preserve explicit enum values that the JS renderer currently emits as implicit ordinals. */ +const applyEnumDiscriminants = (directory: string, root: CodamaRoot): void => { + for (const definedType of root.program.definedTypes) { + if (definedType.type.kind !== "enumTypeNode") continue; + const enumType = definedType as CodamaEnum; + const filename = path.join(directory, "types", `${enumType.name}.ts`); + let source = fs.readFileSync(filename, "utf8"); + + for (const [index, variant] of enumType.type.variants.entries()) { + const discriminator = variant.discriminator ?? index; + const implicit = ` ${upperFirst(variant.name)},`; + const explicit = ` ${upperFirst(variant.name)} = ${discriminator},`; + if (!source.includes(implicit)) { + throw new Error( + `Codama enum variant not found in ${filename}: ${implicit.trim()}`, + ); + } + source = source.replace(implicit, explicit); + } + + fs.writeFileSync(filename, source); + } +}; + +/** Keep the vendored client compatible with the package's Kit >=5 peer range. */ +const renderProgramAddressOnly = ( + directory: string, + root: CodamaRoot, +): void => { + const filename = path.join(directory, "programs", "paymentChannels.ts"); + fs.writeFileSync( + filename, + `/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead rerun the payment-channels generator. + */ + +import type { Address } from "@solana/kit"; + +export const PAYMENT_CHANNELS_PROGRAM_ADDRESS = + "${root.program.publicKey}" as Address<"${root.program.publicKey}">; +`, + ); +}; + +/** Replace parser-only Kit 6 error constants so generated clients keep the package's Kit 5 peer. */ +const applyKit5Compatibility = (directory: string): void => { + const instructionsDir = path.join(directory, "instructions"); + for (const entry of fs.readdirSync(instructionsDir, { + withFileTypes: true, + })) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) continue; + const filename = path.join(instructionsDir, entry.name); + let source = fs.readFileSync(filename, "utf8"); + if ( + !source.includes( + "SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS", + ) + ) { + continue; + } + source = source + .replace( + /^\s*SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS,\n/m, + "", + ) + .replace(/^\s*SolanaError,\n/m, "") + .replace( + /throw new SolanaError\(\s*SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS,\s*\{\s*actualAccountMetas: instruction\.accounts\.length,\s*expectedAccountMetas: (\d+),\s*\},?\s*\);/g, + (_, expected: string) => + `throw new Error(\`insufficient account metas: expected ${expected}, got \${instruction.accounts.length}\`);`, + ); + if ( + source.includes( + "SOLANA_ERROR__PROGRAM_CLIENTS__INSUFFICIENT_ACCOUNT_METAS", + ) + ) { + throw new Error( + `Kit 5 compatibility transform incomplete for ${filename}`, + ); + } + fs.writeFileSync(filename, source); + } +}; + +try { + await codama.accept(renderVisitor(temporaryPackage, { formatCode: true })); + const rendered = path.join(temporaryPackage, "src", "generated"); + if (!fs.existsSync(rendered)) { + throw new Error(`Codama output not found at ${rendered}`); + } + + const root = idl as unknown as CodamaRoot; + applyEnumDiscriminants(rendered, root); + renderProgramAddressOnly(rendered, root); + applyKit5Compatibility(rendered); + fs.rmSync(outputDir, { force: true, recursive: true }); + fs.cpSync(rendered, outputDir, { recursive: true }); + await formatGeneratedTree(outputDir); +} finally { + fs.rmSync(temporaryPackage, { force: true, recursive: true }); +} + +console.log( + `Generated payment-channels client in ${path.relative(packageDir, outputDir)}`, +); diff --git a/scripts/payment-channels-codegen/package.json b/scripts/payment-channels-codegen/package.json new file mode 100644 index 0000000000..541289760b --- /dev/null +++ b/scripts/payment-channels-codegen/package.json @@ -0,0 +1,18 @@ +{ + "name": "@x402/payment-channels-codegen", + "private": true, + "type": "module", + "packageManager": "pnpm@11.1.1", + "scripts": { + "pull-idl": "tsx pull-payment-channels-idl.ts", + "generate": "tsx generate-payment-channels-client.ts", + "check": "pnpm generate && git diff --exit-code -- ../../typescript/packages/mechanisms/svm/src/payment-channels/generated" + }, + "dependencies": { + "@codama/nodes-from-anchor": "1.5.4", + "@codama/renderers-js": "2.3.1", + "codama": "1.10.1", + "prettier": "3.5.2", + "tsx": "4.21.0" + } +} diff --git a/scripts/payment-channels-codegen/pnpm-lock.yaml b/scripts/payment-channels-codegen/pnpm-lock.yaml new file mode 100644 index 0000000000..b53094b5f8 --- /dev/null +++ b/scripts/payment-channels-codegen/pnpm-lock.yaml @@ -0,0 +1,869 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@codama/nodes-from-anchor': + specifier: 1.5.4 + version: 1.5.4 + '@codama/renderers-js': + specifier: 2.3.1 + version: 2.3.1 + codama: + specifier: 1.10.1 + version: 1.10.1 + prettier: + specifier: 3.5.2 + version: 3.5.2 + tsx: + specifier: 4.21.0 + version: 4.21.0 + +packages: + + '@codama/cli@1.6.1': + resolution: {integrity: sha512-U7YtSNIz2AeNue0TM+lZhVtQgoYD9zbuNzRIzaicnrdpAWvVGPfZhoKBOjDyVt7xxr00VF6ilu2wDCgak1yQfQ==} + hasBin: true + + '@codama/errors@1.10.1': + resolution: {integrity: sha512-AOeZ7SuSuCHlhh2irN6nI/YSkEDh+v7+/1ihqstJpYM5mYx67MuKNw1X3COe9nF8e3lkU+eVtq51ILdAJa34jA==} + hasBin: true + + '@codama/fragments@0.1.4': + resolution: {integrity: sha512-hDykTocyNL0nFKbYZjGPJKtAlMKtKM3PZJbzA8FPOFF1H7U2Z8zd+71UrH9jbSEzdnobAeaRqV71LMPg1yUG8w==} + + '@codama/node-types@1.10.1': + resolution: {integrity: sha512-PANkfJ0/1rWwgWPWPRQoCoVbozWOb5YPvYDkEEvqXbnofC1qIioglMwy8pT3N1nJybMwS4J1prk05hSPmhRvag==} + + '@codama/nodes-from-anchor@1.5.4': + resolution: {integrity: sha512-27Nfbu19etgnNvk5wlmbaCyHOVWYPLwXKr4kDTyuO/cmOWMYR8W4ZiFLivVV+1BYbDtNnCWuIn8RuZZSB8X+XQ==} + + '@codama/nodes@1.10.1': + resolution: {integrity: sha512-hcfCCpubRf/BA0vOSEBu95Q8R42yhs0myrqWVCYgv4DQrEqeAiTJxav5NEgmRj8kdFm04Qn854IT4vVXVLP9mA==} + + '@codama/renderers-core@1.3.12': + resolution: {integrity: sha512-LyjFPg66hgfeEI9yV952LSKH4M1UOl1zoIRZgMT25hw5rGQmxxYxNqyKSleqEzJ4uKEO4kAj2WVhFVK4F4sAbA==} + + '@codama/renderers-js@2.3.1': + resolution: {integrity: sha512-+7WtjpcqnlPkweG+tWWiER2XkJP7SF+RJgYpI0RHc91ZP60Umt0qc+0JPuqMyiPhVrLTN2U/lpsBDQw2MeEsvA==} + engines: {node: '>=20.18.0'} + + '@codama/validators@1.10.1': + resolution: {integrity: sha512-cUe+D4gsZttbynBCH1vxTabsW61jFXCcv+FDrb3DNokf+gJKXkiNAyzTMQPazWCAQZkEVIdRkcj+J4vv1sTE+A==} + + '@codama/visitors-core@1.10.1': + resolution: {integrity: sha512-l5dEZ7Ra5KU64y0Nw79CUp4bAlKRQg9m1DlW1nfQtMEByWEzqteOYesKCgK1z3ypCOXqkN9qlrLAqaYSbOa9Vw==} + + '@codama/visitors@1.10.1': + resolution: {integrity: sha512-odsT0HMGpByL4fynLEmVJVoWB1jU5+DNg6pSiJXHmWpJE68mHvgK3tXUTj7eU8zAW+lAjfQWui6pcFp8W4xhJA==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + + '@solana/codecs-core@5.5.1': + resolution: {integrity: sha512-TgBt//bbKBct0t6/MpA8ElaOA3sa8eYVvR7LGslCZ84WiAwwjCY0lW/lOYsFHJQzwREMdUyuEyy5YWBKtdh8Rw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-core@7.1.0': + resolution: {integrity: sha512-pl1gCCvviKekrijEDieZ1ps3LjC6ova2pZ/ZBXEJJa46nF7M0hwdZe/KzbMxKQ8WtT/WqW8Uyh6JQt8IJcMgRw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-data-structures@5.5.1': + resolution: {integrity: sha512-97bJWGyUY9WvBz3mX1UV3YPWGDTez6btCfD0ip3UVEXJbItVuUiOkzcO5iFDUtQT5riKT6xC+Mzl+0nO76gd0w==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-numbers@5.5.1': + resolution: {integrity: sha512-rllMIZAHqmtvC0HO/dc/21wDuWaD0B8Ryv8o+YtsICQBuiL/0U4AGwH7Pi5GNFySYk0/crSuwfIqQFtmxNSPFw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-numbers@7.1.0': + resolution: {integrity: sha512-Hfw5OXx7tbSJ4iQ0r1ar6D+7XixkX8K36ni9cHDrunpiTo9SLIE0Lzj9/889aPNv4J4JggW1bpqzcrz3RI/o5g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/codecs-strings@5.5.1': + resolution: {integrity: sha512-7klX4AhfHYA+uKKC/nxRGP2MntbYQCR3N6+v7bk1W/rSxYuhNmt+FN8aoThSZtWIKwN6BEyR1167ka8Co1+E7A==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: ^5.0.0 + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + + '@solana/codecs-strings@7.1.0': + resolution: {integrity: sha512-YIv/XVDYTzkYiFRdbjK6tI1BUKNlV1ta5hl9oK6+CoCDZn7nECfAN96KPYs94jzbN5VgMgbEqi6tlQcdy5KuCg==} + engines: {node: '>=20.18.0'} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5.4.0' + peerDependenciesMeta: + fastestsmallesttextencoderdecoder: + optional: true + typescript: + optional: true + + '@solana/codecs@5.5.1': + resolution: {integrity: sha512-Vea29nJub/bXjfzEV7ZZQ/PWr1pYLZo3z0qW0LQL37uKKVzVFRQlwetd7INk3YtTD3xm9WUYr7bCvYUk3uKy2g==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/errors@5.5.1': + resolution: {integrity: sha512-vFO3p+S7HoyyrcAectnXbdsMfwUzY2zYFUc2DEe5BwpiE9J1IAxPBGjOWO6hL1bbYdBrlmjNx8DXCslqS+Kcmg==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@solana/errors@7.1.0': + resolution: {integrity: sha512-sJ0mM6SHN7fa6auHARRAGjjDpkFzhx6jmgHZAjliC/xADMfI2IYrTnAwguRV9dVBOsEHicylIXszvsyRb6BmEw==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.4.0' + peerDependenciesMeta: + typescript: + optional: true + + '@solana/options@5.5.1': + resolution: {integrity: sha512-eo971c9iLNLmk+yOFyo7yKIJzJ/zou6uKpy6mBuyb/thKtS/haiKIc3VLhyTXty3OH2PW8yOlORJnv4DexJB8A==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + codama@1.10.1: + resolution: {integrity: sha512-GWbRUN+cbgfNrBNzWkpi0z9JcgQxwkkQLW3L+1xCvJQilHmOIC/Si3Wxv5+AiXUad1jSwkacWfM/utpTMtPS5w==} + hasBin: true + + commander@14.0.2: + resolution: {integrity: sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==} + engines: {node: '>=20'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.2: + resolution: {integrity: sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + json-stable-stringify@1.3.0: + resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} + engines: {node: '>= 0.4'} + + jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + prettier@3.5.2: + resolution: {integrity: sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==} + engines: {node: '>=14'} + hasBin: true + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + +snapshots: + + '@codama/cli@1.6.1': + dependencies: + '@codama/nodes': 1.10.1 + '@codama/visitors': 1.10.1 + '@codama/visitors-core': 1.10.1 + commander: 15.0.0 + picocolors: 1.1.1 + prompts: 2.4.2 + + '@codama/errors@1.10.1': + dependencies: + '@codama/node-types': 1.10.1 + commander: 15.0.0 + picocolors: 1.1.1 + + '@codama/fragments@0.1.4': + dependencies: + '@codama/errors': 1.10.1 + + '@codama/node-types@1.10.1': {} + + '@codama/nodes-from-anchor@1.5.4': + dependencies: + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + '@codama/visitors': 1.10.1 + '@noble/hashes': 2.3.0 + '@solana/codecs': 5.5.1 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@codama/nodes@1.10.1': + dependencies: + '@codama/errors': 1.10.1 + '@codama/node-types': 1.10.1 + + '@codama/renderers-core@1.3.12': + dependencies: + '@codama/errors': 1.10.1 + '@codama/fragments': 0.1.4 + '@codama/nodes': 1.10.1 + '@codama/visitors-core': 1.10.1 + + '@codama/renderers-js@2.3.1': + dependencies: + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + '@codama/renderers-core': 1.3.12 + '@codama/visitors-core': 1.10.1 + '@solana/codecs-strings': 7.1.0 + prettier: 3.9.6 + semver: 7.8.5 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@codama/validators@1.10.1': + dependencies: + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + '@codama/visitors-core': 1.10.1 + + '@codama/visitors-core@1.10.1': + dependencies: + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + json-stable-stringify: 1.3.0 + + '@codama/visitors@1.10.1': + dependencies: + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + '@codama/visitors-core': 1.10.1 + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@noble/hashes@2.3.0': {} + + '@solana/codecs-core@5.5.1': + dependencies: + '@solana/errors': 5.5.1 + + '@solana/codecs-core@7.1.0': + dependencies: + '@solana/errors': 7.1.0 + + '@solana/codecs-data-structures@5.5.1': + dependencies: + '@solana/codecs-core': 5.5.1 + '@solana/codecs-numbers': 5.5.1 + '@solana/errors': 5.5.1 + + '@solana/codecs-numbers@5.5.1': + dependencies: + '@solana/codecs-core': 5.5.1 + '@solana/errors': 5.5.1 + + '@solana/codecs-numbers@7.1.0': + dependencies: + '@solana/codecs-core': 7.1.0 + '@solana/errors': 7.1.0 + + '@solana/codecs-strings@5.5.1': + dependencies: + '@solana/codecs-core': 5.5.1 + '@solana/codecs-numbers': 5.5.1 + '@solana/errors': 5.5.1 + + '@solana/codecs-strings@7.1.0': + dependencies: + '@solana/codecs-core': 7.1.0 + '@solana/codecs-numbers': 7.1.0 + '@solana/errors': 7.1.0 + + '@solana/codecs@5.5.1': + dependencies: + '@solana/codecs-core': 5.5.1 + '@solana/codecs-data-structures': 5.5.1 + '@solana/codecs-numbers': 5.5.1 + '@solana/codecs-strings': 5.5.1 + '@solana/options': 5.5.1 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@5.5.1': + dependencies: + chalk: 5.6.2 + commander: 14.0.2 + + '@solana/errors@7.1.0': + dependencies: + chalk: 5.6.2 + commander: 15.0.0 + + '@solana/options@5.5.1': + dependencies: + '@solana/codecs-core': 5.5.1 + '@solana/codecs-data-structures': 5.5.1 + '@solana/codecs-numbers': 5.5.1 + '@solana/codecs-strings': 5.5.1 + '@solana/errors': 5.5.1 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chalk@5.6.2: {} + + codama@1.10.1: + dependencies: + '@codama/cli': 1.6.1 + '@codama/errors': 1.10.1 + '@codama/nodes': 1.10.1 + '@codama/validators': 1.10.1 + '@codama/visitors': 1.10.1 + + commander@14.0.2: {} + + commander@15.0.0: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-tsconfig@4.14.2: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + isarray@2.0.5: {} + + json-stable-stringify@1.3.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + isarray: 2.0.5 + jsonify: 0.0.1 + object-keys: 1.1.1 + + jsonify@0.0.1: {} + + kleur@3.0.3: {} + + math-intrinsics@1.1.0: {} + + object-keys@1.1.1: {} + + picocolors@1.1.1: {} + + prettier@3.5.2: {} + + prettier@3.9.6: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + resolve-pkg-maps@1.0.0: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sisteransi@1.0.5: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.7 + get-tsconfig: 4.14.2 + optionalDependencies: + fsevents: 2.3.3 diff --git a/scripts/payment-channels-codegen/pull-payment-channels-idl.ts b/scripts/payment-channels-codegen/pull-payment-channels-idl.ts new file mode 100644 index 0000000000..7a698beb68 --- /dev/null +++ b/scripts/payment-channels-codegen/pull-payment-channels-idl.ts @@ -0,0 +1,58 @@ +/** Refresh the vendored payment-channels IDL from its pinned source revision. */ + +import fs from "node:fs"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +type Source = { + repository: string; + revision: string; + path: string; + gitBlob: string; +}; + +const repositoryDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const packageDir = path.join( + repositoryDir, + "typescript", + "packages", + "mechanisms", + "svm", +); +const metadataPath = path.join( + packageDir, + "idl", + "payment-channels.source.json", +); +const idlPath = path.join(packageDir, "idl", "payment-channels.json"); +const source = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Source; +const url = `https://raw.githubusercontent.com/${source.repository}/${source.revision}/${source.path}`; +const response = await fetch(url); + +if (!response.ok) { + throw new Error( + `failed to fetch payment-channels IDL: ${response.status} ${response.statusText}`, + ); +} + +const idl = await response.text(); +JSON.parse(idl); +const bytes = Buffer.from(idl); +const gitBlob = createHash("sha1") + .update(`blob ${bytes.byteLength}\0`) + .update(bytes) + .digest("hex"); +if (gitBlob !== source.gitBlob) { + throw new Error( + `payment-channels IDL blob ${gitBlob} does not match ${source.gitBlob}`, + ); +} +fs.writeFileSync(idlPath, idl); +console.log( + `Updated ${path.relative(packageDir, idlPath)} from ${source.revision}`, +); diff --git a/specs/schemes/batch-settlement/scheme_batch_settlement_svm.md b/specs/schemes/batch-settlement/scheme_batch_settlement_svm.md index 9cf64c0e2f..1555cce37c 100644 --- a/specs/schemes/batch-settlement/scheme_batch_settlement_svm.md +++ b/specs/schemes/batch-settlement/scheme_batch_settlement_svm.md @@ -1,6 +1,6 @@ # SVM `batch-settlement` Scheme: High-Throughput Channel Payments on Solana -> Status: **draft**. Companion to the network-agnostic +> Companion to the network-agnostic > [`scheme_batch_settlement.md`](https://github.com/x402-foundation/x402/blob/main/specs/schemes/batch-settlement/scheme_batch_settlement.md). > This document specifies how the `batch-settlement` scheme is realized on > Solana Virtual Machine (SVM) networks. @@ -8,8 +8,11 @@ ## 1. Purpose `batch-settlement` is the high-throughput x402 scheme: a client deposits once -into an escrow channel, then signs cumulative Ed25519 vouchers for individual -requests. The server verifies each voucher offchain, stores the latest +into an escrow channel, then authorizes cumulative Ed25519 vouchers for +individual requests. By default the client signs each voucher. An optional +server-signer mode lets the client sign an expiring channel-bound bearer proof and +the resource operator sign cumulative vouchers for the actual metered amount +after successful requests. The server verifies each authorization offchain, stores the latest commitment, serves immediately, and later redeems the latest voucher onchain. This removes onchain settlement from the request path. @@ -21,18 +24,17 @@ requests and advances a cumulative watermark. The x402 roles map to the payment-channel program as follows: -- **Client**: channel `payer`; funds deposits and signs per-request vouchers - through `payerAuthorizer`. -- **Payer authorizer**: client-controlled Ed25519 key recorded as channel - `authorized_signer`; signs cumulative vouchers. It MAY equal `payer`. +- **Client**: channel `payer`; funds deposits and either signs per-request + vouchers or signs expiring operator authorizations. +- **Voucher signer**: Ed25519 key recorded as channel `authorized_signer`. In + client mode it is client-controlled and MAY equal `payer`; in server mode + it is the `extra.operator` key controlled by the resource operator. - **Server**: resource provider; receives funds at `payTo`; owns per-channel offchain state, including the accepted cumulative watermark and latest voucher. -- **Receiver authorizer**: optional server-controlled Ed25519 key advertised as - `extra.receiverAuthorizer`. It authenticates server-approved cooperative - close requests to the facilitator. It is an offchain x402 role and is not - recorded in the payment-channel account. It is not required for unilateral - refund or rent recovery. +- **Receiver authorizer**: required server-controlled Ed25519 key advertised as + `extra.receiverAuthorizer` (section 3). It is an offchain x402 role and is + not recorded in the payment-channel account. - **Facilitator / sponsor**: account advertised as `extra.feePayer`; sponsors transaction fees and channel rent. It is the transaction fee payer and channel `rent_payer`, and occupies the channel `payee` lifecycle-authority seat with a @@ -49,9 +51,11 @@ the facilitator: - The facilitator, as `payee`, can run `settle_and_seal` with `has_voucher = 0`, then `distribute` and `reclaim`, even if the client and server disappear. A client/server pair cannot strand the rent it sponsored. -- Only the client-controlled `payerAuthorizer` can sign a voucher that advances - the settled watermark. The facilitator can close at the current onchain - watermark, but it cannot create a nonzero claim or redirect funds. +- Only the channel's `payerAuthorizer` can sign a voucher that advances the + settled watermark. In client mode this is client-controlled. In server + mode the client explicitly delegates that authority to `extra.operator`, + which can sign up to the full channel deposit. The facilitator can close at + the current onchain watermark but cannot redirect funds. A facilitator that closes before the latest client voucher is claimed freezes the watermark and causes the unclaimed remainder to be returned to the client. @@ -63,11 +67,11 @@ claim promptly. | Requirement (generic spec) | SVM mechanism | |---|---| | One-time escrow deposit | Payment-channels `open` deposits escrow, records `withdrawDelay`, fixes `payee`, `authorized_signer`, `rent_payer`, and commits `distribution_hash`. | -| Per-request authorization | Ed25519 voucher signed by `payerAuthorizer` over `0x56 0x01 \|\| channelId \|\| maxClaimableAmount \|\| expiresAt`. | +| Per-request authorization | Client mode: an Ed25519 voucher signed by `payerAuthorizer`. Server mode: an expiring payer proof plus a single-use request identifier; the advertised amount is a ceiling and the operator signs the actual cumulative charge after serving. | | Monotonic amount | Server-owned offchain watermark plus onchain `settled < maxClaimableAmount <= deposit` at redemption. | | Batched redemption | One `settle` per channel, packed transaction-size permitting; `distribute` pays settled deltas. | | Recipient binding | `distribution_hash` fixed at `open` sends funds to `payTo`; program re-checks it at `distribute`. | -| Recovery of unused deposit | Client-signed `request_close`, followed after the grace period by permissionless `seal` + `distribute`; an optional server authorization can replace this with immediate cooperative `settle_and_seal` + `distribute`. | +| Recovery of unused deposit | Section 3. | ## 3. Payment-Channel Method @@ -115,37 +119,98 @@ to rediscover the channels it sponsored as specified in section 6. Token payouts and the return of unused escrow are completed by `distribute` and are not delayed by the later `reclaim` of PDA rent. -For an x402 immediate cooperative close, the facilitator MUST authenticate the -resource server and bind that identity to `payTo` and the channel. It MAY do so -with a valid `CloseAuthorization` signed by `extra.receiverAuthorizer`, or with -facilitator-specific out-of-band authentication established no later than the -channel's first deposit. Neither mechanism replaces the facilitator's onchain -`payee` signature or the client's voucher signature. The facilitator MUST NOT -trust a receiver-authorizer key or claimed server identity merely because it -appears in `PaymentRequirements` or a settlement request. Any trusted binding -is fixed for the channel lifetime; changing it requires closing the channel and -opening a new one. - -For out-of-band authentication, the facilitator MUST record the authenticated -server principal that submitted or sponsored the first deposit together with -the derived `channelId` and verified `payTo`. It MUST require that same principal -on an immediate cooperative-close request. This mechanism is facilitator-local; -the interoperable wire fallback remains payer-signed forced close. - -The facilitator MUST accept a refund without a receiver authorizer. In that -case, the client supplies a payer-signed `request_close` transaction, the -facilitator adds its fee-payer signature and broadcasts it, and the facilitator -or another permissionless crank finalizes the channel after the grace period. -The facilitator MAY use the immediate cooperative path when it receives the -server's latest valid voucher through an authenticated request under that -binding. Out-of-band authentication is an optimization and is not required for -interoperability. +Every channel is bound to one receiver authorizer at open. The client's open +transaction carries, in addition to the seller/nonce Memo, a binding Memo whose +UTF-8 data is exactly: + +```text +x402:batch-settlement:svm:rcvauth:v1: +``` + +The key MUST equal `extra.receiverAuthorizer`. Because the payer signs the open +transaction, the binding is fixed for the channel lifetime; changing it +requires closing the channel and opening a new one. The facilitator MUST NOT +trust a receiver-authorizer key merely because it appears in +`PaymentRequirements` or a settlement request. + +The bound key is either the server's own signer (**self-managed**) or a key the +facilitator advertises and holds (**delegated**). A server with no local signer +MUST copy `extra.receiverAuthorizer` from the facilitator's `/supported` +advertisement and MUST refuse to start when that advertisement is missing or +not a Solana address. A server with a local signer MUST reject a challenge +whose `extra.receiverAuthorizer` is a different key. + +In delegated mode the facilitator authenticates the caller. On the open it +resolves a caller identity and records it, keyed by `(network, channelId)`, +before broadcast. A missing identity MUST be rejected with +`invalid_batch_settlement_svm_delegated_unauthenticated`. The first writer +wins. On `seal` and cooperative `refund` the same identity MUST be presented +again; `closeAuthorization` is then omitted. The identity is not onchain. If +the facilitator loses it, those closes fail closed and the client falls back +to `request_close`. A facilitator that did not opt into delegated +authorization MUST reject a close that omits `closeAuthorization`. + +The facilitator MUST be able to read the binding from at least one of: + +- a store, or +- an explicitly configured history reader that pages the channel account's + signatures to the oldest successful transaction and reads the binding memo + from the base64 `getTransaction` result. + +The history reader is used only when facilitator config sets it. The +facilitator MUST NOT adopt one from the signer or from a public RPC. Startup +MUST fail when both sources are absent. + +Configuring both is allowed. The store is primary; a history read is written +back into the store. Deposit broadcast depends on which sources are configured: + +- Store only: the facilitator MUST bind the open's key and read that row back + before broadcasting. A failed write, or a read-back that is missing or a + different key, MUST NOT send the open. The first writer wins; a different + key MUST be rejected with + `invalid_batch_settlement_svm_receiver_authorizer_mismatch`. +- Store and history: the facilitator attempts the bind. A failed write MUST + NOT abort the open; the deposit is still broadcast, and history remains the + fallback at close. +- History only: the facilitator does not write a store row, because the + transaction is the record, and the open proceeds. + +History depth cannot be checked at startup: the history reader's RPC MUST +retain the open for the life of the channel, and a pruned history is an +unavailable binding. The binding is read only when closing: + +- `deposit`, `voucher`, and `authorization`: no receiver-authorizer check. A + top-up does not re-bind. +- `seal` and cooperative `refund`: the bound key MUST equal + `extra.receiverAuthorizer`. Self-managed closes MUST carry a valid + `CloseAuthorization` from that key. Delegated closes MUST match the caller + identity bound at open and MUST NOT require `closeAuthorization`. A `seal` + without an available binding MUST be rejected. +- `claim` and `settle`: no receiver-authorizer check. + +Neither the binding nor a `CloseAuthorization` replaces the facilitator's +onchain `payee` signature or the close voucher the server supplies at settle +(client-signed in client mode, operator-signed after server enrichment in +server mode). The server MUST +reject an open whose binding Memo is not its own key before forwarding the +deposit. A refund for a channel that is already `Closing` reports that close. + +A refund is a cooperative close by default: the facilitator applies the +server's accepted voucher with `settle_and_seal` and a sealed `distribute` on +the `Open` channel, returning `deposit - settled` to the payer at once. When +the binding is unavailable, the client MAY retry once with a payer-signed +`request_close` transaction. The facilitator adds its fee-payer signature and +broadcasts it, and the facilitator or another permissionless crank finalizes +the channel after the grace period. The idle abandon-close (`extra.maxIdleSecs`, +Phase 4) and the payer's own onchain `request_close` remain the client's +liveness escape when neither the server nor the facilitator cooperates. If the client invokes `request_close`, the channel enters `Closing` and regular `settle` is no longer available. Only the `payee` can apply a final voucher with -`settle_and_seal` during the grace period. After the grace period, anyone can -call `seal`; the payer can recover unspent escrow through `withdraw_payer` or -sealed `distribute`. +`settle_and_seal` during the grace period; the server supplies that voucher +through the `seal` settlement payload of section 4.5, authenticated by its +receiver authorizer. After the grace period, anyone can call `seal`; the payer +can recover unspent escrow through `withdraw_payer` or sealed `distribute`. ## 4. Wire Format @@ -161,7 +226,7 @@ and `SettlementResponse` types are defined in |---|---|---|---| | `scheme` | string | yes | `"batch-settlement"` | | `network` | string | yes | CAIP-2, e.g. `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | -| `amount` | string | yes | Fixed per-request price in atomic units. | +| `amount` | string | yes | Per-request amount in atomic units. In server mode this is the maximum charge; the actual charge is selected after handling and MUST NOT exceed it. | | `asset` | string | yes | Concrete SPL / Token-2022 mint pubkey, not a symbol. | | `payTo` | string | yes | Base58 final payment receiver. Normally a server cold wallet. | | `maxTimeoutSeconds` | number | yes | HTTP completion window. | @@ -173,12 +238,16 @@ and `SettlementResponse` types are defined in |---|---|---|---| | `paymentFlow` | string | no | When present, MUST be `"authorization"`. The scheme resolves to the protocol-default `authorization` flow: read-only verification runs before the resource handler, and `/settle` commits the voucher — and broadcasts any `deposit` transaction — after it. | | `feePayer` | string | yes | Base58 sponsor key set as channel `rent_payer` and zero-share `payee`. Co-signs setup/top-up transactions as transaction fee payer and signs channel lifecycle transactions. | -| `receiverAuthorizer` | string | no | Base58 server-controlled Ed25519 key that authenticates an optional immediate cooperative close to the facilitator. It is not a payment-channel account field. | +| `receiverAuthorizer` | string | yes | Base58 server-controlled Ed25519 key (section 3). Not a payment-channel account field. | +| `voucherSigner` | string | no | Voucher-signing mode: `"client"` (default) or `"server"`. A client MUST NOT act on a `"server"` accept unless it has trusted that operator out of band (section 8); a server advertising `"server"` SHOULD list a `"client"` accept for the same resource alongside it so clients that do not extend that trust can still pay. | +| `operator` | string | conditional | Base58 resource-operator Ed25519 key. REQUIRED when `voucherSigner == "server"`; MUST be absent in client mode. | | `withdrawDelay` | number | yes | Forced-close grace period in seconds. MUST be an integer from `900` through `2592000` (15 minutes through 30 days), MUST be `>= maxTimeoutSeconds`, and MUST be encoded exactly as the program `grace_period`. The payment-channels program accepts any positive `grace_period`; this range is an x402 conformance bound, so verifying facilitators MUST enforce it and reject out-of-range requirements. | | `tokenProgram` | string | yes | SPL Token (`Tokenkeg...`) or Token-2022 (`TokenzQ...`) program that owns `asset`. The client and facilitator MUST verify it against the onchain mint owner. | | `memo` | string | no | Seller-defined UTF-8 payment reference for the setup transaction's Memo instruction. Maximum 256 bytes. | | `recentBlockhash` | string | no | Pre-fetched blockhash the client MAY use to build an `open` or `top_up` transaction without an RPC round trip. The client MUST refresh it if it is no longer valid. | | `recentSlot` | number | no | Recent slot the client MAY use as `channelConfig.openSlot` when it does not fetch its own slot. The program still enforces the open-slot window. | +| `minDeposit` | string | no | Atomic deposit target. When present, MUST be a positive integer greater than or equal to `amount`. | +| `maxIdleSecs` | number | no | Facilitator idle window in seconds, copied from the facilitator's `/supported` `extra`. After this long with no facilitator-visible lifecycle activity on an `Open` channel, the facilitator MAY abandon-close it at the onchain `settled` watermark (see Phase 4). Absent means the facilitator does not idle-close. | | `channelState` | object | no | Corrective-only server channel snapshot for cumulative amount resynchronization. | | `voucherState` | object | no | Corrective-only signed voucher proof for cumulative amount resynchronization. | @@ -187,6 +256,14 @@ transaction-construction hints only. They are not persistent channel configuration and are not included in the voucher message. A client MAY ignore the hints and obtain fresher values from an RPC. +Clients SHOULD use a conforming `extra.minDeposit` as the deposit target and +SHOULD enforce a local maximum deposit so a 402 cannot lock unbounded escrow. +Servers SHOULD NOT reject a deposit solely because `deposit.amount` is below +this field. A server that applies a local minimum-deposit policy MAY reject it +and MUST return +`invalid_batch_settlement_svm_deposit_below_min_deposit`. The facilitator MUST +NOT enforce `minDeposit`. + The x402 wire format does not expose program-specific split arrays. The client derives the payment-channel accounts and distribution from the x402 fields: @@ -220,6 +297,7 @@ Example: "receiverAuthorizer": "", "withdrawDelay": 3600, "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", + "minDeposit": "10000", "memo": "invoice-123", "recentBlockhash": "", "recentSlot": 341000000 @@ -239,12 +317,13 @@ may expose those program fields with language-specific casing. | x402 wire field | Payment-channels program notation | |---|---| | `PaymentRequirements.extra.feePayer` | `Channel.rent_payer` and zero-share `Channel.payee` | -| `PaymentRequirements.extra.receiverAuthorizer` | No program field; optional trusted server key for offchain close authorization | +| `PaymentRequirements.extra.receiverAuthorizer` | No program field. Section 3. | | `PaymentRequirements.extra.tokenProgram` | `open.token_program` / `top_up.token_program`; MUST equal the onchain owner of `Channel.mint` | | `channelConfig.payer` | `Channel.payer` | | `channelConfig.payerAuthorizer` | `Channel.authorized_signer` | +| `channelConfig.voucherSigner` | No program field; omitted or `"client"` means client mode, while `"server"` binds `payerAuthorizer` to `PaymentRequirements.extra.operator` | | `channelConfig.receiver` | Sole `DistributionEntry.recipient` with `bps = 10000`; the `Channel.payee` implicit remainder is always zero | -| `channelConfig.receiverAuthorizer` | No program field; when supplied, MUST equal `PaymentRequirements.extra.receiverAuthorizer` | +| `channelConfig.receiverAuthorizer` | No program field; MUST equal `PaymentRequirements.extra.receiverAuthorizer` | | `channelConfig.token` | `Channel.mint` | | `channelConfig.withdrawDelay` | `Channel.grace_period` | | `channelConfig.salt` | `Channel.salt` | @@ -262,13 +341,14 @@ may expose those program fields with language-specific casing. | Field | Type | Notes | |---|---|---| | `payer` | string | Client wallet and channel payer. MUST NOT equal `feePayer`, because the program requires distinct payer and payee accounts. | -| `payerAuthorizer` | string | Client-controlled Ed25519 voucher signer; maps to channel `authorized_signer`. MAY equal `payer` but MUST NOT equal `feePayer`. | +| `payerAuthorizer` | string | Ed25519 voucher signer; maps to channel `authorized_signer`. In client mode it is client-controlled and MAY equal `payer`. In server mode it MUST equal `extra.operator`. It MUST NOT equal `feePayer`. | | `receiver` | string | MUST equal `payTo`. | -| `receiverAuthorizer` | string | Optional. When supplied, MUST equal `extra.receiverAuthorizer`. This key authenticates cooperative close requests offchain and is not a channel PDA seed or program account field. | +| `receiverAuthorizer` | string | Required. MUST equal `extra.receiverAuthorizer` (section 3). Not a channel PDA seed or program account field. | | `token` | string | MUST equal `asset`; maps to channel `mint`. | | `withdrawDelay` | number | MUST equal `extra.withdrawDelay`; maps to channel `grace_period`. | | `salt` | string | Decimal `u64` channel salt. | | `openSlot` | number | `u64` slot encoded in `open` and used as a channel PDA seed. | +| `voucherSigner` | string | Optional. Omitted or `"client"` selects client mode; `"server"` selects server mode and MUST equal `extra.voucherSigner`. | The client and facilitator MUST confirm `extra.tokenProgram` equals the onchain owner of `channelConfig.token`; neither role may trust the server-provided value @@ -322,12 +402,45 @@ The signed message is exactly: This is the payment-channels program voucher layout (`VOUCHER_MAGIC`, `channel_id`, `cumulative_amount`, `expires_at`), 50 bytes total. +`BatchAuthorization` is the expiring bearer proof for server mode: + +| Field | Type | Notes | +|---|---|---| +| `type` | string | MUST be `"proof"`. | +| `channelId` | string | Channel PDA (base58). | +| `payer` | string | MUST equal `channelConfig.payer`. | +| `requestId` | string | Fresh opaque single-use request identifier. | +| `authorizedAmount` | string | Maximum charge in atomic units; MUST equal `PaymentRequirements.amount`. | +| `expiresAt` | number | Integer Unix seconds. The server MUST require `now < expiresAt`. | +| `signature` | string | Base58 payer signature over the authorization message below. | + +The client signs exactly the concatenation of these bytes: + +```text +utf8("x402-batch-authorization-v2") || +base58_decode(channelId) || +base58_decode(payer) || +base58_decode(extra.operator) || +u16(len(utf8(requestId))).le || utf8(requestId) || +u64(authorizedAmount).le || +i64(expiresAt).le +``` + +Each decoded address MUST be exactly 32 bytes, and `requestId` MUST encode to 1 +through 256 UTF-8 bytes. The proof authorizes one request up to +`authorizedAmount` for that channel, payer, and operator until `expiresAt`. It +MUST be handled as a bearer credential. Clients SHOULD set `expiresAt` no later +than their current time plus `maxTimeoutSeconds` and MUST issue a fresh proof +for each HTTP request. The request and amount bindings limit a leaked proof to +one bounded request; expiry limits how long it can be presented. None of these +fields revoke the operator's onchain authority to sign vouchers for the channel. + `CloseAuthorization`: | Field | Type | Notes | |---|---|---| | `validBefore` | number | Integer Unix seconds. The server MUST set it no later than its current time plus `maxTimeoutSeconds`; the facilitator MUST require `now < validBefore <= now + maxTimeoutSeconds`. | -| `signature` | string | Base58 Ed25519 signature by the receiver authorizer bound to the server through `extra.receiverAuthorizer` or a trusted out-of-band registration. | +| `signature` | string | Base58 Ed25519 signature by the channel's bound receiver authorizer (section 3). Omitted on a delegated close; the facilitator authenticates the caller instead. | The receiver authorizer signs the SHA-256 digest of this exact byte sequence: @@ -357,15 +470,14 @@ program deployment, facilitator, channel, voucher watermark, or expiry. | `balance` | string | Current `Channel.deposit` ceiling in atomic units. | | `totalClaimed` | string | Current onchain `Channel.settled` watermark. | | `withdrawRequestedAt` | number | `Channel.closure_started_at`, or `0` when no forced close is pending. | -| `chargedCumulativeAmount` | string | Server-owned offchain cumulative fixed charge. Present only when the response is authored by the server. | +| `chargedCumulativeAmount` | string | Server-owned offchain cumulative actual charge. Present only when the response is authored by the server. | ### 4.3 Client `PaymentPayload` Variants The client payload is a tagged union on `payload.type`. `/verify` accepts all -three variants. `/settle` accepts `deposit` and `refund` directly. The server -MAY enrich `refund` with its latest accepted voucher and, when needed, a -`CloseAuthorization` before forwarding it as specified in section 4.5. A -`voucher` is accepted offchain by the server. +four variants. `/settle` accepts `deposit` and `refund` directly. The server +enriches `refund` with a `CloseAuthorization` before forwarding it as +specified in section 4.5. A `voucher` is accepted offchain by the server. **`deposit`** opens a channel or tops up an existing channel and authorizes the current request: @@ -374,7 +486,8 @@ current request: |---|---|---| | `type` | string | `"deposit"` | | `channelConfig` | `ChannelConfig` | Full channel configuration. | -| `voucher` | `BatchVoucher` | Cumulative authorization for the current request. | +| `voucher` | `BatchVoucher` | REQUIRED in client mode and absent in server mode. Cumulative authorization for the current request. | +| `authorization` | `BatchAuthorization` | REQUIRED in server mode and absent in client mode. Expiring payer proof. | | `deposit.amount` | string | Amount to deposit or top up in atomic units. | | `deposit.transaction` | string | Base64 client-signed `open` or `top_up` transaction for the facilitator to validate, co-sign, and broadcast. | @@ -473,21 +586,51 @@ transaction: } ``` -**`refund`** is retained as the cross-binding wire discriminator for a channel -close. The client MUST provide a payer-signed `request_close` transaction. This -is a payment operation, not a paid resource request, so the application -resource handler MUST be bypassed. The request MUST NOT contain an `amount`: -the payment-channel program supports only the return of all unused escrow, and -the closed channel cannot be reused. A client MAY also provide a voucher as a -hint for an immediate cooperative close, but the facilitator MUST NOT apply it -unless an authenticated server confirms it is the latest accepted voucher. +**`authorization`** is the server-mode steady-state request. It carries no +client-signed voucher and no transaction: + +| Field | Type | Notes | +|---|---|---| +| `type` | string | `"authorization"` | +| `channelConfig` | `ChannelConfig` | MUST select server mode. | +| `authorization` | `BatchAuthorization` | Expiring, single-request, amount-bounded payer proof. | + +```json +{ + "type": "authorization", + "channelConfig": { + "payer": "", + "payerAuthorizer": "", + "receiver": "", + "token": "", + "withdrawDelay": 3600, + "salt": "42", + "openSlot": 341000000, + "voucherSigner": "server" + }, + "authorization": { + "type": "proof", + "channelId": "", + "payer": "", + "requestId": "", + "authorizedAmount": "1000", + "expiresAt": 1758215100, + "signature": "" + } +} +``` + +**`refund`** closes the channel (section 3). It is not a paid resource request, +so the resource handler MUST be bypassed, and the payload MUST NOT contain an +`amount`. | Field | Type | Notes | |---|---|---| | `type` | string | `"refund"` | | `channelConfig` | `ChannelConfig` | Full channel configuration. | -| `transaction` | string | Base64 client-signed `request_close` transaction. The transaction MUST set `extra.feePayer` as its Solana fee payer so the facilitator can co-sign and broadcast it. | -| `voucher` | `BatchVoucher` | Optional latest accepted voucher. It is used only when supplied or confirmed by an authenticated server for immediate cooperative close. | +| `voucher` | `BatchVoucher` | REQUIRED in client mode. The server's accepted cumulative amount (`expiresAt` MUST be `0`). MUST be absent in server mode on the client-authored payload. | +| `authorization` | `BatchAuthorization` | REQUIRED in server mode. `authorizedAmount` MUST be `"0"` (close intent). MUST be absent in client mode. | +| `transaction` | string | Omitted unless retrying after `invalid_batch_settlement_svm_receiver_binding_unavailable` (section 3). Base64 payer-signed `request_close` with `extra.feePayer` as the Solana fee payer. | ```json { @@ -501,6 +644,7 @@ unless an authenticated server confirms it is the latest accepted voucher. "maxTimeoutSeconds": 300, "extra": { "feePayer": "", + "receiverAuthorizer": "", "withdrawDelay": 3600, "tokenProgram": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA", "recentBlockhash": "", @@ -513,12 +657,18 @@ unless an authenticated server confirms it is the latest accepted voucher. "payer": "", "payerAuthorizer": "", "receiver": "", + "receiverAuthorizer": "", "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", "withdrawDelay": 3600, "salt": "42", "openSlot": 341000000 }, - "transaction": "" + "voucher": { + "channelId": "", + "maxClaimableAmount": "5000", + "expiresAt": 0, + "signature": "" + } } } ``` @@ -534,8 +684,9 @@ unless an authenticated server confirms it is the latest accepted voucher. | `network` | string | yes | CAIP-2 network identifier. | | `amount` | string | no | Amount moved onchain; empty for voucher acceptance and `claim`. | | `extra.commitmentId` | string | no | MUST be non-empty for voucher acceptance, e.g. `channelId:maxClaimableAmount`. | -| `extra.chargedAmount` | string | no | Fixed per-request charge; MUST equal `PaymentRequirements.amount`. | +| `extra.chargedAmount` | string | yes | Actual charge for this request. | | `extra.channelState` | `ChannelState` | no | Current channel snapshot. | +| `extra.voucher` | `BatchVoucher` | conditional | REQUIRED in server mode. This operator-signed cumulative voucher is the receipt. | Scheme-specific response fields are nested under `extra`. A successful voucher-only response is: @@ -549,13 +700,19 @@ A successful voucher-only response is: "amount": "", "extra": { "commitmentId": ":5000", - "chargedAmount": "1000", + "chargedAmount": "2000", "channelState": { "channelId": "", "balance": "100000", "totalClaimed": "3000", "withdrawRequestedAt": 0, "chargedCumulativeAmount": "5000" + }, + "voucher": { + "channelId": "", + "maxClaimableAmount": "5000", + "expiresAt": 0, + "signature": "" } } } @@ -576,15 +733,18 @@ The standard x402 `POST /verify` and `POST /settle` request envelope contains #### `POST /verify` `paymentPayload.payload` MUST be one of the client-authored `deposit`, `voucher`, -or `refund` variants in section 4.3. The facilitator validates the wire fields, +`authorization`, or `refund` variants in section 4.3. The facilitator validates the wire fields, voucher signature, derived channel PDA, and onchain channel state and, for -`deposit` and `refund`, the client-signed transaction. For `refund`, it -additionally confirms that the transaction is an exact payer-authorized -`request_close` for the derived channel, the channel is `Open` or is still -within its `Closing` grace period, and the payer's canonical return ATA is -healthy. If a voucher is present, the facilitator MAY validate it during -verification, but MUST only apply it after authenticating the server through a -trusted request context or a valid `CloseAuthorization`. +`deposit` and `refund`, the client-signed transaction. For `authorization`, and +for a `deposit` whose `channelConfig` selects server mode, it MUST also verify +the `BatchAuthorization` itself: the payer signature over the section 4.2 +message, `channelId` equal to the derived PDA, `payer` equal to +`channelConfig.payer`, `authorizedAmount` equal to `PaymentRequirements.amount`, +a non-empty `requestId`, and `now < expiresAt`. A facilitator used as a +standalone verifier therefore never accepts a server-mode request on the +resource server's word alone. For `refund`, follow section 3. The channel +MUST be `Open` or still within its `Closing` grace period, and the payer's +canonical return ATA MUST be healthy. | Response field | Type | Required | Notes | |---|---|---|---| @@ -625,49 +785,62 @@ instruction it invokes: |---|---|---|---| | `deposit` | Client | Open a channel or add escrow. | `open` / `top_up` | | `voucher` | Client | Accept an offchain authorization; no funds move. | None | +| `authorization` | Client | Ask the operator to accept and sign the next cumulative voucher; no funds move. | None | | `claim` | Server | Advance accounting; no funds move to the receiver. | `settle` | | `settle` | Server | Move earned funds to the receiver. | `distribute` | -| `refund` | Client, optionally server-enriched | Start a payer-forced close; after the grace period, finalize and return all unused escrow, or use an authenticated immediate cooperative close. | `request_close` then `seal` + sealed `distribute`, or `settle_and_seal` + sealed `distribute` | +| `seal` | Server | Finalize a `Closing` channel with the server's latest voucher during the grace period; pays the receiver and returns unused escrow to the payer. | `settle_and_seal` + sealed `distribute` | +| `refund` | Client, server-enriched | Section 3. | `settle_and_seal` + sealed `distribute`, or `request_close` | The examples in this subsection show the inner `paymentPayload.payload`; the caller MUST wrap each one in the standard request envelope defined above. The `deposit` settle payload is the client variant from section 4.3. A `voucher` -is stored and answered by the server without a facilitator settlement. The -server-authored or server-enriched variants are: +is stored and answered by the server without a facilitator settlement. Every +server-authored payload identifies a channel by the same entry shape, +`channelId` plus `channelConfig`, and carries vouchers as `BatchVoucher` +objects; `claim` and `settle` wrap one to four such entries in an array, while +`seal` acts on one channel. The server-authored or server-enriched variants +are: | Payload | Field | Type | Notes | |---|---|---|---| | Claim | `type` | string | `"claim"` | -| Claim | `claims` | array | One to four voucher claims. | -| Claim | `claims[].voucher.channelConfig` | `ChannelConfig` | Full channel configuration. | -| Claim | `claims[].voucher.channelId` | string | Channel PDA. | -| Claim | `claims[].voucher.maxClaimableAmount` | string | Cumulative amount for program `settle`. | -| Claim | `claims[].voucher.expiresAt` | number | MUST be `0`. | -| Claim | `claims[].signature` | string | Base58 Ed25519 voucher signature. | +| Claim | `claims` | array | One to four channels to claim in one transaction. | +| Claim | `claims[].channelId` | string | Channel PDA. | +| Claim | `claims[].channelConfig` | `ChannelConfig` | Full channel configuration. | +| Claim | `claims[].voucher` | `BatchVoucher` | Latest accepted voucher; its `maxClaimableAmount` becomes the new onchain `settled` watermark. `expiresAt` MUST be `0`. | | Settle | `type` | string | `"settle"` | -| Settle | `channels` | array | One or more channels to distribute. | +| Settle | `channels` | array | One to four channels to distribute in one transaction. | | Settle | `channels[].channelId` | string | Channel PDA. | | Settle | `channels[].channelConfig` | `ChannelConfig` | Full configuration used to derive accounts, validate the channel, and reconstruct the distribution. | +| Seal | `type` | string | `"seal"` | +| Seal | `channelId` | string | Channel PDA. | +| Seal | `channelConfig` | `ChannelConfig` | Full channel configuration. | +| Seal | `voucher` | `BatchVoucher` | Latest accepted voucher; its `maxClaimableAmount` becomes the final settled watermark. `expiresAt` MUST be `0`. | +| Seal | `closeAuthorization` | `CloseAuthorization` | REQUIRED (section 3). Binds this exact close. | | Refund | `type` | string | `"refund"` | | Refund | `channelConfig` | `ChannelConfig` | Client-provided channel configuration. | -| Refund | `transaction` | string | Client-signed `request_close` transaction forwarded to the facilitator. | -| Immediate cooperative close | `voucher` | `BatchVoucher` | Latest accepted client voucher supplied or confirmed by the authenticated server. | -| Immediate cooperative close | `closeAuthorization` | `CloseAuthorization` | Optional server signature authorizing the immediate cooperative close. Omit when trusted out-of-band request authentication supplies the server binding. | +| Refund | `voucher` | `BatchVoucher` | Client mode: section 4.3. Server mode: injected by the server from stored operator state before settle. | +| Refund | `authorization` | `BatchAuthorization` | Server mode only on the client payload (section 4.3). | +| Refund | `transaction` | string | Section 4.3. | +| Refund | `closeAuthorization` | `CloseAuthorization` | Added by the server (section 3). | The server forwards a client-authored `deposit` unchanged. It MAY settle -`voucher` locally by storing the commitment and producing the response in -section 4.4. +`voucher` or `authorization` locally by storing the commitment and producing +the response in section 4.4. The facilitator validates channel and deposit +bindings for server-mode payloads, but the expiring payer proof is verified +by the authenticated resource server because it is a bearer credential for +that server; it is not a facilitator payment authorization. For `refund`, the server MUST serialize processing with every paid request and -other close for the channel, bypass the resource handler, and forward the -client-signed `request_close` transaction after successful facilitator -verification. For the unilateral path, once the request-close transaction -confirms, the server MUST reject new paid requests for the channel. The server -MAY instead provide the latest voucher through a facilitator-authenticated -request for an immediate cooperative close. When the facilitator does not -authenticate the request out of band, the server MUST also create a fresh -`CloseAuthorization` with `extra.receiverAuthorizer`: +other close for the channel, and bypass the resource handler. In client mode it +MUST reject a voucher whose `maxClaimableAmount` differs from its accepted +cumulative amount. In server mode it MUST verify the payer `authorization` +(`authorizedAmount` `"0"`), MUST reject a client-supplied `voucher`, MUST inject +the stored operator voucher at the accepted cumulative, and MUST add a +`CloseAuthorization` over that amount before forwarding (section 3). Once a +sponsored `request_close` confirms, the server MUST reject new paid requests for +the channel: ```json { @@ -682,7 +855,6 @@ authenticate the request out of band, the server MUST also create a fresh "salt": "42", "openSlot": 341000000 }, - "transaction": "", "voucher": { "channelId": "", "maxClaimableAmount": "5000", @@ -696,11 +868,9 @@ authenticate the request out of band, the server MUST also create a fresh } ``` -If the server supplied a cooperative voucher, the facilitator MUST authenticate -the server either from its trusted request context or by validating -`closeAuthorization` against the trusted receiver-authorizer binding. It MUST -reject a supplied `CloseAuthorization` at or after `validBefore`, and MUST -validate the voucher against the channel. When +When the binding in section 3 is available, the facilitator MUST validate +`closeAuthorization` and the voucher against the channel. It MUST reject a +`CloseAuthorization` at or after `validBefore`. When `Channel.settled < voucher.maxClaimableAmount`, it emits the voucher's Ed25519 precompile instruction immediately followed by `settle_and_seal` with `has_voucher = 1`. When the values are equal, it invokes @@ -710,8 +880,8 @@ MUST be rejected. It then invokes sealed `distribute` in the same transaction, verifies the confirmed onchain result, and returns the full amount `deposit - settled` transferred to the payer. -Without a valid cooperative authorization, the facilitator MUST validate and -co-sign the client's `request_close` transaction with `extra.feePayer`, +When the binding is unavailable, the facilitator MUST validate and co-sign the +client's `request_close` transaction with `extra.feePayer`, broadcast it, and confirm that the channel entered `Closing`. It MUST NOT fabricate or apply a final voucher. After `Channel.closure_started_at + Channel.grace_period`, the facilitator MUST schedule and attempt `seal` followed @@ -722,49 +892,51 @@ by the same worker through `reclaim`; none of these asynchronous transactions is part of the initial HTTP refund response. The server initiates asynchronous accounting with a `claim` payload. Each -claim carries the full channel configuration needed to derive and validate the -channel plus the voucher signature needed to construct the Ed25519 precompile -instruction: +entry carries the full channel configuration needed to derive and validate the +channel plus the latest accepted voucher, whose signature builds the Ed25519 +precompile instruction: ```json { "type": "claim", "claims": [ { + "channelId": "", + "channelConfig": { + "payer": "", + "payerAuthorizer": "", + "receiver": "", + "receiverAuthorizer": "", + "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "withdrawDelay": 3600, + "salt": "42", + "openSlot": 341000000 + }, "voucher": { - "channelConfig": { - "payer": "", - "payerAuthorizer": "", - "receiver": "", - "receiverAuthorizer": "", - "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "withdrawDelay": 3600, - "salt": "42", - "openSlot": 341000000 - }, "channelId": "", "maxClaimableAmount": "5000", - "expiresAt": 0 - }, - "signature": "" + "expiresAt": 0, + "signature": "" + } }, { + "channelId": "", + "channelConfig": { + "payer": "", + "payerAuthorizer": "", + "receiver": "", + "receiverAuthorizer": "", + "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "withdrawDelay": 3600, + "salt": "43", + "openSlot": 341000050 + }, "voucher": { - "channelConfig": { - "payer": "", - "payerAuthorizer": "", - "receiver": "", - "receiverAuthorizer": "", - "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", - "withdrawDelay": 3600, - "salt": "43", - "openSlot": 341000050 - }, "channelId": "", "maxClaimableAmount": "7000", - "expiresAt": 0 - }, - "signature": "" + "expiresAt": 0, + "signature": "" + } } ] } @@ -814,9 +986,65 @@ a `settle` payload: } ``` -The facilitator invokes `distribute` for every `channels[]` entry and MUST -process every entry or fail the request. It MUST confirm each transaction -onchain before returning success. +`channels[]` MUST contain between one and four entries, the same +transaction-size bound as `claims[]`. The facilitator invokes `distribute` for +every `channels[]` entry in one transaction and MUST process every entry or +fail the request; it MUST NOT silently truncate a batch. It MUST confirm the +transaction onchain before returning success. + +Once the payer has broadcast `request_close`, the channel is `Closing` and +program `settle` is unavailable: a `claim` for that channel, and a `voucher` or +`authorization` verification against it, MUST fail with +`invalid_batch_settlement_svm_channel_closing`. The server's only way to +collect voucher value above the onchain `settled` watermark is a `seal` +payload, which the facilitator executes as `payee` before the grace period +ends. A server that sees that code SHOULD mark the channel closing and seal it +on its next redemption pass, and SHOULD stop once the grace period has passed. +It SHOULD set `withdrawDelay` comfortably above its claim cadence so the grace +period always covers a scheduled redemption: + +```json +{ + "type": "seal", + "channelId": "", + "channelConfig": { + "payer": "", + "payerAuthorizer": "", + "receiver": "", + "receiverAuthorizer": "", + "token": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "withdrawDelay": 3600, + "salt": "42", + "openSlot": 341000000 + }, + "voucher": { + "channelId": "", + "maxClaimableAmount": "5000", + "expiresAt": 0, + "signature": "" + }, + "closeAuthorization": { + "validBefore": 1785341100, + "signature": "" + } +} +``` + +The facilitator MUST authenticate a `seal` as specified in section 3, requiring +`now < validBefore <= now + maxTimeoutSeconds`. A key that merely appears in +the request MUST NOT be trusted, because a payer holding an older voucher could +otherwise freeze the watermark below the server's latest. It MUST verify the voucher signature +against `channelConfig.payerAuthorizer`, confirm the channel is `Closing` with +`now < closure_started_at + grace_period`, and require `Channel.settled <= +voucher.maxClaimableAmount <= Channel.deposit`. When `settled < +maxClaimableAmount` it emits the voucher's Ed25519 precompile instruction +immediately followed by `settle_and_seal` with `has_voucher = 1`; when equal it +invokes `settle_and_seal` with `has_voucher = 0`. It then invokes sealed +`distribute` in the same transaction, confirms it onchain, and reports +`amount` as the funds moved to `payTo`; the payer receives `deposit - +maxClaimableAmount` in the same transaction. Any other state MUST be rejected +with `invalid_batch_settlement_svm_close_state`. A `seal` occupies the +`("close", channelId, maxClaimableAmount)` namespace of Phase 5. Successful responses have operation-specific `amount` semantics: @@ -842,6 +1070,37 @@ Settle (`amount` is the total transferred to the receiver across the batch): } ``` +A facilitator MAY add per-channel confirmation under `extra`: for a claim, +`extra.accepts[]` entries of `{ "channelId", "totalClaimed" }` giving the +confirmed onchain `settled` value the claim advanced each channel to; for a +settle, `extra.channels[]` listing the channel PDAs the transaction paid. A +server MUST NOT require either field. When they are absent, it reconciles the +`settled` and `payout_watermark` values by reading the channel account after +the transaction confirms; when they are present, it MAY use them only if they +name exactly the channels it submitted. + +Seal (`amount` is the delta paid to the receiver; `extra.channelState` +carries the final watermark, and `balance - totalClaimed` went back to the +payer): + +```json +{ + "success": true, + "transaction": "", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "payer": "", + "amount": "2000", + "extra": { + "channelState": { + "channelId": "", + "balance": "100000", + "totalClaimed": "5000", + "withdrawRequestedAt": 0 + } + } +} +``` + Deposit (`amount` is the amount deposited or topped up): ```json @@ -865,8 +1124,8 @@ Deposit (`amount` is the amount deposited or topped up): } ``` -Refund initiation (`amount` is empty because the grace period may still be -running): +Sponsored refund initiation (section 3; `amount` is empty because the grace +period may still be running): ```json { @@ -892,23 +1151,26 @@ channel entered `Closing`; it MUST NOT claim that the refund amount has moved until a later `seal`/`distribute` cleanup confirms it. For this unilateral path, the server MUST mark the channel close-pending and reject further paid requests. A later session MUST open a new channel with a new `openSlot`. - -When the immediate cooperative shortcut is used, the response instead contains -the final cooperative-close signature and `amount = deposit - settled`; the -server marks the channel closed after confirmation. +A cooperative refund (section 3) instead returns the close signature and +`amount = deposit - settled`. #### `GET /supported` -The facilitator advertises its SVM transaction fee payer. The server MUST copy -that value into `PaymentRequirements.extra.feePayer`, then set -`extra.tokenProgram` from the selected asset's verified mint owner. The scheme -resolves to the protocol-default `authorization` payment flow, so -`extra.paymentFlow` is normally omitted; when either party emits it, the value -MUST be `"authorization"`. The server -MAY also set `extra.receiverAuthorizer` to a key covered by a trusted -facilitator registration when it wants signed immediate cooperative closes. -The receiver authorizer is server-owned and is therefore not advertised by the -facilitator: +The facilitator advertises its SVM transaction fee payer and, when it runs +idle rent cleanup, the idle window `maxIdleSecs` (a positive integer number of +seconds; the reference implementation defaults to `604800`, seven days). A +facilitator that accepts delegated closes also advertises +`extra.receiverAuthorizer`, the key it binds into those channels. The server +MUST copy `feePayer` and `maxIdleSecs` into `PaymentRequirements.extra`, then +set `extra.tokenProgram` from the selected asset's verified mint owner. A +server SHOULD claim every channel well inside `maxIdleSecs`, because the +facilitator may close an idle channel at its onchain `settled` watermark and +any voucher value above it is then forfeited. The scheme resolves to the +protocol-default `authorization` payment flow, so `extra.paymentFlow` is +normally omitted; when either party emits it, the value MUST be +`"authorization"`. The server MUST set `extra.receiverAuthorizer` (section 3): +its own signer when it self-manages closes, otherwise the facilitator's +advertised key: ```json { @@ -918,7 +1180,8 @@ facilitator: "scheme": "batch-settlement", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "extra": { - "feePayer": "" + "feePayer": "", + "maxIdleSecs": 604800 } } ], @@ -984,6 +1247,14 @@ new cumulative base and retry. When the server has no accepted voucher for the channel, it omits `voucherState` and the client resynchronizes from onchain state instead. +In server mode `voucherState.signature` is the operator's, so it proves only +that the operator produced the snapshot, not that the client authorized it. A +server-mode client MUST additionally reject a corrective snapshot whose +`chargedCumulativeAmount` exceeds its own confirmed watermark plus the sum of +`PaymentRequirements.amount` for its requests on that channel whose outcome it +never observed. That sum is the most the client has agreed the operator may +have charged. + ## 5. Phases ### Phase 1 - Open or Top Up @@ -1030,10 +1301,9 @@ not supported by this version of the scheme. and zero-share `payee` account positions. For `top_up`, it MUST NOT appear in the payment-channel instruction's account list. In either form it MUST NOT be invoked as a program or used by any other instruction. -- When either receiver-authorizer field is supplied, the two values MUST be - equal, and the facilitator MUST validate that key against its trusted binding - for the authenticated server and `payTo`. It is not a required transaction - signer or payment-channel instruction account. +- `channelConfig.receiverAuthorizer` and `extra.receiverAuthorizer` MUST both + be present and equal (section 3). The receiver authorizer is not a transaction + signer or a payment-channel instruction account. ##### Top-level instruction layout @@ -1048,7 +1318,9 @@ The top-level instructions MUST consist only of the following ordered regions: (`MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr`) and at most three Lighthouse instructions (`L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95`). The Memo data MUST be `extra.memo` as UTF-8 when supplied, or otherwise a random nonce of - at least 16 bytes encoded as hexadecimal text. + at least 16 bytes encoded as hexadecimal text. An `open` suffix also carries + exactly one binding Memo from section 3. The signed `open` transaction MUST + be at most 1232 bytes. No other top-level instruction or program is allowed. In particular, a top-level associated-token-account instruction, arbitrary wallet program, @@ -1069,8 +1341,8 @@ A sponsor MAY apply stricter local compute-unit, priority-fee, or required-signature caps, but MUST NOT relax the absolute maxima above. Lighthouse and Memo instructions are allowed only in the final suffix and MUST NOT reference `extra.feePayer` as an account or as the invoked program. If -`extra.memo` is present, the facilitator MUST require exactly one Memo and an -exact UTF-8 data match. A sponsor MAY reject all optional Lighthouse +`extra.memo` is present, the facilitator MUST require exactly one Memo besides +the binding Memo and an exact UTF-8 data match. A sponsor MAY reject all optional Lighthouse instructions, but MUST NOT reject the required Memo or admit instructions outside this allowlist. @@ -1143,6 +1415,15 @@ MUST re-read the channel and verify its status, deposit, mint, payer, payee, authorized signer, rent payer, grace period, open slot, and distribution against the payload and requirements before reporting settlement success. +The payer MUST provision its canonical return ATA. The resource server MUST +provision the canonical `payTo` recipient ATA before advertising these +requirements, and the payment-channel deployment operator MUST provision the +canonical treasury ATA for each supported mint and token program. A client MUST +NOT be required to create or fund an ATA owned by the resource server or +payment-channel treasury. When a required ATA is absent, a facilitator SHOULD +identify whether the payer, recipient, or treasury ATA is missing in its +settlement-simulation error. + Under the `authorization` flow, the `open` or `top_up` transaction is broadcast by the post-handler `/settle`, after the statically validated `deposit` request passes Phase 3 and the resource handler succeeds. After the transaction @@ -1182,11 +1463,23 @@ observed initiation state as the idempotent result. ### Phase 2 - Steady-State Request -Using the last authenticated `PAYMENT-RESPONSE`, the client sets +In client mode, using the last authenticated `PAYMENT-RESPONSE`, the client sets `maxClaimableAmount = channelState.chargedCumulativeAmount + PaymentRequirements.amount`, signs a new `BatchVoucher`, and sends a `voucher` -payload. No onchain transaction is required in the request path. The server -verifies and stores the voucher under Phase 3, then serves immediately. +payload. In server mode it sends an `authorization` payload with an unexpired +payer proof binding a fresh `requestId` and `PaymentRequirements.amount`; it +does not propose a cumulative amount. A client enters server mode only for an +operator it has trusted out of band; when a 402 offers both modes for the same +resource, a client that holds such trust SHOULD prefer the server-mode accept +and every other client MUST fall back to the client-mode accept. +The client MUST keep at most one server-mode request in flight per channel so +the returned cumulative voucher can be evaluated against one exact local +watermark. A transport retry is a new x402 request and MUST use a new +`requestId`; applications requiring response recovery SHOULD use the payment +identifier extension. No onchain transaction is required in the request path. +The server verifies the authorization under Phase 3, reserves up to +`PaymentRequirements.amount`, then after the handler measures the actual charge +and stores an operator-signed voucher for that amount. For a new channel, `chargedCumulativeAmount` starts at zero. ### Phase 3 - Voucher Acceptance (before serving) @@ -1195,15 +1488,19 @@ The server is the sole owner of per-channel offchain state. A separate facilitator, if used, remains stateless for ordinary voucher acceptance; it only needs onchain state plus a voucher when it later settles. -The server MUST serialize all paid-request and close processing per -channel. -The server stores `chargedCumulativeAmount`, `signedMaxClaimable`, the latest -voucher signature, and a cached paid-response entry keyed by -`("access", channelId, maxClaimableAmount)`. For every paid-request voucher, -the server MUST: - -1. Verify the Ed25519 signature over the 50-byte message using - `channelConfig.payerAuthorizer` and confirm that key equals the channel +The server MUST serialize only reservation, final accounting, and close +transitions per channel. Server-mode resource handlers MAY execute concurrently +outside that short critical section. The server stores +`chargedCumulativeAmount`, `signedMaxClaimable`, the latest voucher signature, +the last time its mirrored onchain fields were refreshed, and server-mode +operations in a store keyed by `(channelId, requestId)`. For every paid +request, the server MUST: + +1. In client mode, verify the Ed25519 signature over the 50-byte voucher message + using `channelConfig.payerAuthorizer`. In server mode, verify the expiring + payer proof using `channelConfig.payer`, require `now < authorization.expiresAt`, confirm its operator equals + `extra.operator`, and confirm `channelConfig.payerAuthorizer == + extra.operator`. In both modes the payer authorizer MUST equal the channel `authorized_signer`. 2. Confirm `channelId` matches the PDA derived from `channelConfig` and the canonical payment-channels program id. @@ -1212,30 +1509,61 @@ the server MUST: channelConfig.payerAuthorizer`, `rent_payer == extra.feePayer`, `grace_period == channelConfig.withdrawDelay == extra.withdrawDelay`, `open_slot == channelConfig.openSlot`, and a distribution to `payTo` - matching section 4.1. When `channelConfig.receiverAuthorizer` is supplied, - confirm it equals `extra.receiverAuthorizer`; otherwise both fields MUST be - absent. Reject if `payer` or `payerAuthorizer` equals `feePayer`. + matching section 4.1. `channelConfig.receiverAuthorizer` MUST equal + `extra.receiverAuthorizer` (section 3). Reject if `payer` or + `payerAuthorizer` equals `feePayer`. + The server MUST obtain these fields from facilitator verification for every + `deposit` and `refund`. It MAY verify a steady-state `voucher` or + `authorization` locally only while its mirrored onchain state is within a + bounded freshness interval. A missing or stale snapshot MUST fall through + to facilitator verification, and a successful verification MUST refresh the + stored snapshot timestamp. For server-mode `refund`, verify the payer proof + (`authorizedAmount` `"0"`, fresh `requestId`, expiry) and reject any + client-supplied `voucher` before reserving the close. 4. **No voucher expiry.** The client MUST sign `expiresAt = 0`, and the server and facilitator MUST reject any voucher with nonzero `expiresAt`. The forced-close grace period already bounds the redemption window after a payer `request_close`; a per-voucher expiry would add a second clock the server has to beat and could make an accepted voucher unredeemable while the channel is still open, after the resource has been served. -5. Enforce the deposit cap: `maxClaimableAmount <= channel.deposit`. -6. Enforce replay protection and the per-request ceiling: - - A previously accepted `("access", channelId, maxClaimableAmount)` is an - idempotent retry. Return its cached response and do not execute the resource - handler again. - - Any other `maxClaimableAmount <= signedMaxClaimable` is stale and MUST be - rejected. - - A fresh voucher MUST have `maxClaimableAmount == - chargedCumulativeAmount + PaymentRequirements.amount`. -7. Execute the resource handler. Only after it succeeds, set `chargedAmount = - PaymentRequirements.amount`, atomically add `chargedAmount` to - `chargedCumulativeAmount`, store `signedMaxClaimable`, the voucher and the - cached response, and return `PAYMENT-RESPONSE` with `transaction == ""`, - `extra.commitmentId`, `extra.chargedAmount`, and `extra.channelState`. If the - handler fails, state MUST remain unchanged so the client can retry. +5. Reserve capacity before running the handler. In client mode, require + `maxClaimableAmount == chargedCumulativeAmount + PaymentRequirements.amount` + and `maxClaimableAmount <= channel.deposit`. In server mode, reserve the + advertised ceiling under the request's single-use identifier and enforce: + + ```text + sum(activeReservation.ceiling) <= deposit - chargedCumulativeAmount + ``` + + A failed or canceled handler MUST release the reservation without charging. + A close transition MUST NOT be accepted while any paid request remains + reserved. +6. Enforce replay protection. Reject stale or already accepted client vouchers, + and atomically create at most one server-mode operation for each + `(channelId, requestId)`. Any duplicate operation, whether running or + completed, MUST be rejected with `duplicate_settlement` and MUST NOT execute + the handler. The scheme does not replay either the settlement response or + the application resource body. +7. Execute the resource handler. In client mode, the actual charge is the + advertised amount. In server mode, after metering completes, choose an actual + charge satisfying `0 <= chargedAmount <= PaymentRequirements.amount` and set + `newCumulative = chargedCumulativeAmount + chargedAmount`. Only after handler + success, atomically store the new cumulative amount and voucher, then return + `PAYMENT-RESPONSE` with `transaction == ""`, `extra.commitmentId`, + `extra.chargedAmount`, `extra.channelState`, and in server mode the + operator-signed `extra.voucher`. The server-mode client MUST + verify that voucher against `channelConfig.payerAuthorizer`, derive + `chargedAmount = voucher.maxClaimableAmount - localConfirmed`, and require + `0 <= chargedAmount <= PaymentRequirements.amount`. On any mismatch it MUST + leave local state unchanged and resynchronize through a corrective 402. The + server MUST durably bind `requestId` to the ceiling, actual charge, and + resulting cumulative amount before acknowledging success. + +Different operations MAY complete in any order. Each completion briefly locks +the channel, adds its actual charge to the then-current watermark, signs that +new cumulative value, commits the operation, and releases its reservation. +Production implementations using separate channel and operation stores MUST +make that transition atomic or provide equivalent recovery semantics. For a `deposit` payload, the `open` or `top_up` transaction is broadcast by the post-handler `/settle`, so the checks above run against the statically @@ -1245,11 +1573,11 @@ evaluated against the transaction's instruction fields with channel with its deposit increased by `payload.deposit.amount`. The server accepts the risk that a validated deposit transaction later fails to confirm; because earlier vouchers were capped by the confirmed deposit, that exposure is -bounded by the single request's `amount`. +bounded by the single request's advertised ceiling. On any failure, the server returns `402` without serving the resource. If the -server has local channel state and the client submits the wrong cumulative -amount, the server SHOULD return a corrective 402 with +server has local channel state and a client-mode payload submits the wrong +cumulative amount, the server SHOULD return a corrective 402 with `accepts[].extra.channelState` and `accepts[].extra.voucherState`, as shown in section 4.6. @@ -1266,23 +1594,21 @@ the request path: - **Settle (`type: "settle"`).** Call program `distribute` to pay the newly claimed delta to `payTo` and advance `payout_watermark`. The channel remains open. -- **Refund (`type: "refund"`).** The facilitator broadcasts the client's - payer-signed `request_close` transaction with its fee-payer signature. After - the configured grace period, it or another permissionless crank calls `seal` - and sealed `distribute`. `distribute` pays any remaining settled delta, - returns the full `deposit - settled` remainder to the payer, closes the - escrow token account, and moves the channel to its cleanup state. If the - server supplies or confirms the final voucher through an authenticated - request, the facilitator MAY skip the wait and use `settle_and_seal` plus - sealed `distribute` immediately. +- **Seal (`type: "seal"`).** For a channel the payer has moved to `Closing`, + the server submits its latest voucher with a `CloseAuthorization`; the + facilitator, as `payee`, applies it with `settle_and_seal` and pays out with + sealed `distribute` before the grace period ends. This is how a server + collects voucher value above the onchain watermark once the payer walks. +- **Refund (`type: "refund"`).** Section 3. `distribute` pays any remaining + settled delta, returns `deposit - settled` to the payer, closes the escrow + token account, and moves the channel to its cleanup state. - **Rent cleanup.** If final `distribute` leaves the channel in `Distributed`, anyone can later call `reclaim` after the open-slot window to return remaining PDA rent to `rent_payer`. -The close authorization is required only for the optional immediate -cooperative-close optimization. It does not remove the facilitator's -independent permissionless lifecycle authority to finalize a forced close at -the current onchain watermark under its published rent-recovery policy. +The close authorization does not remove the facilitator's independent +permissionless lifecycle authority to finalize a forced close at the current +onchain watermark under its published rent-recovery policy. The payer exits through the payment-channel program's forced-close path. The server SHOULD claim with enough buffer before `withdrawDelay` can elapse after @@ -1292,16 +1618,35 @@ server has authenticated that close. After the grace period, anyone can call `seal`; the payer can recover unspent deposit via `withdraw_payer` or sealed `distribute`, and vouchers not yet claimed are forfeited by the server. +**Idle abandon-close.** Vouchers in this scheme never expire, so an `Open` +channel whose payer walks away would lock sponsored rent forever. A facilitator +that advertises `extra.maxIdleSecs` MAY, once an `Open` channel has seen no +facilitator-visible lifecycle activity for that many seconds, close it at the +current onchain `settled` watermark with `settle_and_seal` (`has_voucher = +0`), then `distribute` and `reclaim`. Facilitator-visible activity is any +`deposit`, `claim` or `settle` the facilitator processed for the channel; +offchain voucher acceptance on the server does not reset the clock. Closing at +the watermark forfeits every voucher the server has not claimed, so the server +MUST treat `maxIdleSecs` as a second clock it races against, and SHOULD claim +long before it elapses (the reference channel manager redeems on an interval +well inside the seven-day default). A facilitator MUST NOT idle-close earlier +than the window it advertised, and MUST NOT apply an idle policy it does not +advertise. + ### Phase 5 - Duplicate and Concurrent Operation Handling The cumulative voucher and payment-channel state machine prevent duplicate -token movement, but HTTP retries still require explicit operation-level -idempotency: - -- **Paid requests (`deposit` and `voucher`).** The server's per-channel lock and - `("access", channelId, maxClaimableAmount)` cache are the authoritative replay - defense. The same authorization MUST NOT execute the resource handler more - than once, regardless of whether a retry changes from `deposit` to `voucher`. +token movement. Core x402 requests are single-shot; scheme state prevents a +duplicate request from executing but is not an HTTP response-recovery protocol: + +- **Server-mode paid requests.** The operation record is the authoritative + replay defense. Reuse of a running or completed `(channelId, requestId)` MUST + return `duplicate_settlement`; it MUST NOT replay a settlement response or + execute the handler. Applications that need to recover a resource body after + a lost response SHOULD use the payment identifier extension. +- **Client-mode paid requests.** The per-channel lock and charged watermark are + the authoritative replay defense. The same authorization MUST NOT execute the + resource handler more than once. - **Client-supplied transactions.** For `deposit` and `refund`, the facilitator SHOULD maintain a short-lived in-flight cache keyed by the exact serialized transaction or its first signature. Concurrent `/settle` calls for the same @@ -1320,13 +1665,10 @@ idempotency: watermark. - **Refunds.** The facilitator MUST coalesce retries of the same `request_close` transaction into one broadcast and one initiation result. - Once `Closing`, later retries return the observed channel state. A client - refund does not occupy the cooperative-close authorization namespace and does - not require a server signature. If the server supplies a cooperative close, - the latest voucher and its authorization occupy a distinct `("close", - channelId, maxClaimableAmount)` namespace; the server and facilitator MUST - cache that result through `closeAuthorization.validBefore` or the replay - window of the trusted out-of-band request authentication. + Once `Closing`, later retries return the observed channel state. A `seal` or + server-enriched refund occupies the `("close", channelId, maxClaimableAmount)` + namespace; the server and facilitator MUST cache that result through + `closeAuthorization.validBefore`. - **Reclaim.** A channel can transition from `Distributed` to deallocated only once. Concurrent reclaim attempts require no additional replay mitigation; later attempts observe an absent account or invalid status. @@ -1338,7 +1680,7 @@ discover channels whose `payer` equals its wallet. A facilitator can discover every channel for which it fronted rent by querying `rent_payer` or, equivalently in this scheme, `payee`. The server still requires durable offchain storage for the accepted charge watermark, unclaimed voucher, request correlation, and -cached responses; those values cannot be reconstructed from channel accounts. +replay state; those values cannot be reconstructed from channel accounts. Implementations MAY retain a local lifecycle index, but a facilitator MUST be able to rebuild the onchain portion after local state loss, at startup, and @@ -1394,10 +1736,12 @@ request path. After startup or local state loss, a rent sponsor MUST: 2. Refetch and revalidate a channel immediately before acting. If another worker or user changes its status, refetch and reclassify it instead of treating the stale transition failure as permanent. -3. For an `Open` channel, allow the server a policy-defined notice or idle - timeout to submit its latest voucher. The facilitator MAY then close at the - current onchain watermark using `settle_and_seal` with `has_voucher = 0`, - followed by `distribute` and, when necessary, `reclaim`. +3. For an `Open` channel, wait until it has been idle for the advertised + `extra.maxIdleSecs` (Phase 4), giving the server that window to submit its + latest voucher. The facilitator MAY then close at the current onchain + watermark using `settle_and_seal` with `has_voucher = 0`, followed by + `distribute` and, when necessary, `reclaim`. A channel rediscovered after + local state loss starts its idle clock at rediscovery. 4. For a `Closing` channel, schedule a recheck at the grace deadline. During the grace period, the facilitator MAY apply a final voucher supplied by the server when it has a valid cooperative authorization; afterward the normal @@ -1411,6 +1755,25 @@ Onchain recovery does not recreate an unclaimed voucher or the server's a charge. The facilitator's conservative recovery action is to close at the current onchain `settled` watermark and return the remainder to the payer. +### 6.3 Reference Storage Boundaries + +The reference implementation separates channel accounting from request +operations. Its channel store owns the cumulative watermark and active +reservations; its operation store provides the conceptual operations: + +```text +reserveOperation(channelId, requestId, ceiling) +completeOperation(channelId, requestId, actual, cumulative) +getOperation(channelId, requestId) +``` + +The included in-memory stores demonstrate the state machine and per-key locks. +They are not production persistence. Implementers may replace them with a +database, object store, distributed lock, HSM-backed signer, or another design, +provided reservation and completion invariants survive process failure and +concurrent workers. Retention, sharding, monitoring, and application-result +storage are implementation concerns. + ## 7. Error Codes Standard x402 codes apply. The facilitator reports verification failures in @@ -1430,19 +1793,26 @@ Standard x402 codes apply. The facilitator reports verification failures in match the canonical PDA derivation. - `invalid_batch_settlement_svm_fee_payer_mismatch` - channel `payee` or `rent_payer` does not match `extra.feePayer`. -- `invalid_batch_settlement_svm_receiver_authorizer_mismatch` - - `channelConfig.receiverAuthorizer` does not match - `extra.receiverAuthorizer` or the facilitator's trusted server binding. +- `invalid_batch_settlement_svm_receiver_authorizer_mismatch` - the bound key + in section 3 does not match `extra.receiverAuthorizer`. +- `invalid_batch_settlement_svm_receiver_binding_unavailable` - the binding in + section 3 is unavailable. +- `invalid_batch_settlement_svm_delegated_unauthenticated` - a delegated open, + seal, or cooperative refund has no caller identity, or the identity does not + match the one bound at open. - `invalid_batch_settlement_svm_close_authorization` - close authorization is - malformed, expired, signed by the wrong receiver authorizer, does not bind the - exact cooperative-close request, or the out-of-band request principal does - not match the server principal bound at deposit. + missing, malformed, expired, signed by a key other than the channel's bound + receiver authorizer, or does not bind the exact cooperative-close request. - `invalid_batch_settlement_svm_close_amount_unsupported` - the `"refund"` wire payload contains an `amount`; this scheme supports only returning the full unused escrow. - `invalid_batch_settlement_svm_close_state` - the channel cannot be closed, or - an optional cooperative voucher does not equal server state or is behind the + a cooperative voucher does not equal server state or is behind the channel's onchain `settled` value. +- `invalid_batch_settlement_svm_channel_closing` - the payer has started a + forced close, so program `settle` is unavailable: a `claim`, `voucher`, or + `authorization` for the channel is refused and the server retries with a + `seal` payload inside the grace period. - `invalid_batch_settlement_svm_withdraw_delay_mismatch` - channel grace period does not match `extra.withdrawDelay`. - `invalid_batch_settlement_svm_withdraw_delay_out_of_range` - `withdrawDelay` @@ -1457,33 +1827,74 @@ Standard x402 codes apply. The facilitator reports verification failures in sponsor safety checks. - `invalid_batch_settlement_svm_settlement_simulation` - setup or settlement-readiness simulation/checks failed before accepting the deposit. +- `invalid_batch_settlement_svm_deposit_below_min_deposit` - the server applies + a local minimum-deposit policy and rejected a deposit below the advertised + `extra.minDeposit` target. - `invalid_batch_settlement_svm_channel_state` - confirmed channel state does not match the payload and challenge-bound requirements. - `invalid_batch_settlement_svm_refund_transaction` - refund transaction is not a valid payer-signed `request_close` for the derived channel or contains an unauthorized instruction. -- `duplicate_settlement` - the same client-supplied setup or refund transaction - is already being settled. +- `invalid_batch_settlement_svm_payout_attribution_ambiguous` - a `settle` + transaction confirmed, but the receiver's token-balance delta cannot be + attributed to the batch because a sealed payout in the same transaction + returned escrow to a payer or treasury that aliases `payTo`. The facilitator + returns the confirmed signature with this reason instead of an `amount`; + the server reconciles from onchain state. +- `duplicate_settlement` - a server-mode `requestId` was already reserved or + completed, or the same client-supplied setup or refund transaction is already + being settled. ## 8. Security Properties - **Bounded authorization.** Onchain `settle` rejects vouchers above `deposit` - and sets the exact cumulative maximum signed by the client. + and sets the exact cumulative maximum signed by the selected voucher signer. +- **Operator delegation is explicit and high trust.** Selecting server mode + records `extra.operator` as the onchain `authorized_signer`. The operator can + therefore take up to the full deposit without another client signature or + proof that it delivered service. The blast radius is `deposit`, which may be + much greater than one `PaymentRequirements.amount`; in client mode it is only + the amount of the voucher the client chose to sign. Clients SHOULD prefer + client mode. A client choosing server mode SHOULD use small, short-lived + deposits and top up frequently. Over-provisioning is theft exposure in server + mode, not merely temporarily locked client funds. +- **Server mode is opt-in per operator, never per 402.** A client MUST NOT open + or pay into a server-mode channel because a `PaymentRequired` advertised + `voucherSigner: "server"`. It MUST require an explicit, locally configured + allowlist of `extra.operator` keys. The grant is keyed by operator alone so + it holds on every transport; nothing carried in the 402 body, and no + transport-specific attribute such as a request origin, is a trust anchor. + The grant SHOULD carry a maximum escrow the client will lock per channel + under that operator, expressed per asset; the client MUST clamp every + deposit hint, including `extra.minDeposit`, to that cap, since the cap is + exactly what a dishonest operator could take. Absent a grant the client MUST + refuse the server-mode accept and SHOULD pay a client-mode accept for the + same resource, network, and asset at no more than the refused amount when + one is offered; servers advertising server mode SHOULD therefore also offer + the same resource in client mode, typically at the request ceiling as a + fixed price. Because the + channel derivation binds `voucherSigner` and `operator`, a server that later + changes either cannot reuse a channel the client opened under the old terms; + the new terms go through the same trust decision. +- **Bearer-proof confinement.** The proof binds the channel, payer, operator, + single-use request identifier, amount ceiling, and expiry under a versioned + domain. It MUST NOT be logged or sent to any origin other than the authorized + resource server. The request binding limits a stolen proof to one bounded + request, while `expiresAt` limits how long it can be presented. Servers MAY additionally require an + authenticated session, audience binding, or a per-request payer signature. + Proof expiry does not remove the operator's onchain signing authority; + closing the channel is the only protocol-level revocation mechanism. - **No redirection.** `distribution_hash` is fixed at `open` and re-checked at `distribute`; the derived distribution sends settled funds to `payTo`. - **Authenticated vouchers.** The wire `extra.feePayer` address is recorded as program `Channel.rent_payer` and zero-share `Channel.payee`. Program `Channel.authorized_signer` instead comes from wire `channelConfig.payerAuthorizer`, so the facilitator can close a channel but - cannot advance `settled` without a client-signed voucher. The committed - distribution prevents payout redirection. -- **Optional authenticated cooperative closes.** The facilitator uses the - authenticated server request only for the immediate cooperative shortcut. A - key or principal supplied in an untrusted request is not itself a trust - anchor: the facilitator binds a receiver-authorizer key through trusted - registration, or records the authenticated server principal, `channelId`, and - `payTo` at deposit. The client voucher independently binds the final - cumulative spend. + cannot advance `settled` without a voucher from the selected signer. In + server mode the payer's channel-bound proof delegates that signing role. + The committed distribution prevents payout redirection. +- **Authenticated cooperative closes.** Section 3. The client voucher + independently binds the final cumulative spend. - **Committed value is preserved at close.** Both forced and cooperative close paths pay any settled delta to `payTo`, return exactly `deposit - settled` to the payer, and close the escrow. Partial unused-escrow returns and channel @@ -1491,7 +1902,9 @@ Standard x402 codes apply. The facilitator reports verification failures in - **Facilitator rent recovery.** Because the facilitator is channel `payee`, it can run `settle_and_seal` with `has_voucher = 0`, then `distribute` and `reclaim`, without client or server cooperation. Abandoned channels cannot - permanently lock sponsored rent. + permanently lock sponsored rent. The idle window that triggers this is + published as `extra.maxIdleSecs`, so the forfeiture it implies is a known, + bounded term rather than an opaque facilitator policy. - **Facilitator early-close exposure.** Closing before the latest voucher is claimed freezes the onchain watermark and returns the remainder to the client. The server MUST bound its exposure by claiming promptly and SHOULD @@ -1499,27 +1912,37 @@ Standard x402 codes apply. The facilitator reports verification failures in client-initiated grace period, only the facilitator can apply the final voucher, and only after authenticating the server. - **No replay / no rollback.** Server offchain watermark plus onchain - `settled` monotonicity reject old vouchers. Paid-request equality is accepted - only as an idempotent replay of a cached access response. Refund initiation - is idempotent by the client-signed `request_close` transaction; optional - cooperative closes use a separate operation namespace and a terminal onchain + `settled` monotonicity reject old vouchers. Server-mode request identifiers + are single use; duplicates are rejected rather than replayed. Clients keep + one request in flight per channel and only advance from their local confirmed + watermark. A sponsored `request_close` is idempotent by that transaction. + Cooperative closes use a separate operation namespace and a terminal onchain transition. - **Bounded sponsor exposure.** Client-supplied transactions have a static instruction allowlist, exact signer and account layouts, no address lookup tables, bounded compute-unit limit and price, and transaction simulation. Outside the prescribed `open` rent roles, the facilitator signature authorizes network fees only. -- **Client escape hatch.** `withdrawDelay` is fixed at `open`; if the server - does not settle, the payer can start forced close and recover unspent escrow - after the grace period. The protocol bounds that delay to 15 minutes through - 30 days and never shorter than the HTTP completion window. +- **Client escape hatch.** `withdrawDelay` is fixed at `open`; the payer can + start forced close and recover whatever remains after the grace period. In + server mode `request_close` does not freeze the voucher watermark: during the + grace period the operator can still submit a voucher up to the full deposit. + Forced close therefore creates a race and is not protection against a + malicious operator that already holds the onchain signing authority. The + protocol bounds the delay to 15 minutes through 30 days and never shorter + than the HTTP completion window. - **Single settlement clock.** Vouchers never expire (`expiresAt` is fixed at `0`); the channel's forced-close path alone bounds the commitment. The server beats one clock — the grace period after a payer `request_close` — and an accepted voucher can never become unredeemable while the channel is open. -- **Fixed pricing.** Each fresh voucher increases the cumulative authorization - by exactly `PaymentRequirements.amount`. The server trusts itself to redeem - before forced-close expiry. +- **Metered server pricing assumes an honest operator.** In server mode the + server treats `PaymentRequirements.amount` as a ceiling, reports an actual + post-handler charge, and returns a cumulative voucher from which the client + derives that charge. The ceiling, metering result, and voucher are all + operator-controlled; the facilitator does not verify service delivery or + pricing. Client verification is post-hoc detection and accounting, not + prevention of overcharging. In client mode each fresh voucher increases the + cumulative authorization by exactly `PaymentRequirements.amount`. ## 9. Out of Scope diff --git a/specs/schemes/exact/scheme_exact.md b/specs/schemes/exact/scheme_exact.md index fe27f3c2a9..139c2fceee 100644 --- a/specs/schemes/exact/scheme_exact.md +++ b/specs/schemes/exact/scheme_exact.md @@ -82,11 +82,19 @@ While implementation details vary by network, facilitators MUST enforce security ### Starknet -- Facilitator safety: the submitting executor MUST come from facilitator configuration, never client input, and MUST NOT be the payer or the recipient. +- Facilitator safety: the submitting executor MUST come from facilitator configuration, never client input, and MUST NOT be the payer (it MAY equal `payTo` for merchant-sponsored settlement). - Transfer correctness: the signed SNIP-9 OutsideExecution MUST contain exactly one call — `transfer` on `requirements.asset` with calldata `[payTo, amount_low, amount_high]` — and the u256 amount MUST equal `requirements.amount` exactly. - Signature validity: the SNIP-12 hash MUST be computed from the facilitator's own canonical reconstruction of the typed data and validate via SNIP-6 `is_valid_signature`. - Caller binding and expiry: `Caller` MUST equal `extra.feePayer` — the required sponsor address the facilitator announces via `/supported` and the resource server puts in the requirements; `Execute Before` MUST cover the advertised `maxTimeoutSeconds` window (within a skew margin) at verification, with a minimum remaining window at settlement. -- Replay protection: the SNIP-9 nonce MUST be unused at verification; it is consumed on-chain at execution. -- Simulation verification: MUST simulate the settlement and fail closed unless it shows exactly one asset `Transfer` from payer to `payTo` for the exact amount. +- Replay protection: the SNIP-9 nonce MUST be unused at verification; it is consumed onchain at execution. +- Simulation verification: MUST simulate the settlement and fail closed unless it shows exactly one asset `Transfer` sent by the payer, to `payTo`, for the exact amount. -Network-specific rules are in per-network documents: `scheme_exact_svm.md` (Solana), `scheme_exact_stellar.md` (Stellar), `scheme_exact_evm.md` (EVM), `scheme_exact_sui.md` (SUI), `scheme_exact_ton.md` (TON), `scheme_exact_starknet.md` (Starknet). +### Hedera + +- Fee payer safety: the fee payer MUST NOT be debited beyond the network fee. Under `cryptoTransfer` it MUST NOT appear as a negative entry in any transfer list; under `transferExecutor` its net HBAR debit MUST equal the transaction fee, or the fee plus `requirements.amount` when it is also the payer. +- Transfer correctness: the net credit to `payTo` in `asset` MUST equal `requirements.amount` exactly, and no other account may be credited in `asset` beyond the network's fee collection accounts. +- Payer signature validity (`cryptoTransfer`): the facilitator MUST confirm the payload carries a signature satisfying the payer's on-chain account key before sponsoring. +- Executor admission (`transferExecutor`): the executor is client-chosen contract code, so admission MUST be default deny, the call MUST be built by the facilitator rather than from client calldata, and the gas limit MUST be the facilitator's own. +- Outcome from the record (`transferExecutor`): settlement success MUST be established from the merged parent and child consensus records, never from the receipt status. + +Network-specific rules are in per-network documents: `scheme_exact_svm.md` (Solana), `scheme_exact_stellar.md` (Stellar), `scheme_exact_evm.md` (EVM), `scheme_exact_sui.md` (SUI), `scheme_exact_ton.md` (TON), `scheme_exact_starknet.md` (Starknet), `scheme_exact_lnbtc.md` (Bitcoin Lightning), `scheme_exact_hedera.md` (Hedera). diff --git a/specs/schemes/exact/scheme_exact_hedera.md b/specs/schemes/exact/scheme_exact_hedera.md index ee3860ccdf..46d654c9d8 100644 --- a/specs/schemes/exact/scheme_exact_hedera.md +++ b/specs/schemes/exact/scheme_exact_hedera.md @@ -8,7 +8,51 @@ This scheme facilitates payments of a specific amount of a Hedera HTS fungible t `exact` -## Protocol Flow +## Asset Transfer Methods + +The `exact` scheme on Hedera executes a transfer where the Facilitator pays the network fee, but the +Client controls the exact flow of funds. This is implemented via one of two asset transfer methods, +depending on who controls the payer's funds: + +| AssetTransferMethod | Mechanism | Payer universe | Recommendation | +| --- | --- | --- | --- | +| **1. `cryptoTransfer`** (default) | Client signs a `TransferTransaction`; facilitator co-signs as fee payer | Accounts whose key controls the funds | **Recommended** for key-controlled accounts (simplest, no contract) | +| **2. `transferExecutor`** | Client supplies an authorization for a contract implementing `ITransferExecutor`; facilitator calls `executeTransfer` as fee payer | Funds held by a contract, or spendable only through a contract holding a HIP-336 allowance (smart accounts, multisig / vault contracts, delegation and spend-policy contracts) | **Contract account option** | + +If no `assetTransferMethod` is specified in `PaymentRequirements.extra`, clients and facilitators +MUST behave as `cryptoTransfer`. Payment payloads that use a non-default transfer method MUST echo +the selected `assetTransferMethod` in `accepted.extra`. A facilitator MUST reject a value it does not +implement (`invalid_exact_hedera_unsupported_asset_transfer_method`) and MUST NOT fall back to +another method. + +In all cases, the Facilitator cannot modify the amount or destination. It only broadcasts the +transaction and sponsors its fee. + +Each method is defined in its own section below. Rules stated under one method apply to that method +only: verification rule 1, "MUST be a `TransferTransaction` directly", is a `cryptoTransfer` rule. + +Facilitators SHOULD advertise the methods they implement per network in `/supported`: + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "hedera:testnet", + "extra": { + "feePayer": "0.0.9000", + "assetTransferMethods": ["cryptoTransfer", "transferExecutor"], + "executors": ["0.0.7000"] + } + } + ] +} +``` + +## AssetTransferMethod: `cryptoTransfer` + +### Protocol Flow The protocol flow for `exact` on Hedera is client-driven. @@ -27,7 +71,7 @@ The protocol flow for `exact` on Hedera is client-driven. 13. Upon successful on‑chain settlement, the **Facilitator Server** responds with a `SettlementResponse` to the **Resource Server**. 14. **Resource Server** grants the **Client** access to the resource in its response. -## `PaymentRequirements` for `exact` +### `PaymentRequirements` for `exact` In addition to the standard x402 `PaymentRequirements` fields, the `exact` scheme on Hedera requires the following inside the `extra` field: @@ -50,7 +94,7 @@ In addition to the standard x402 `PaymentRequirements` fields, the `exact` schem - `payTo`: The Hedera account ID of the resource server receiving the funds. - `extra.feePayer`: The Hedera account ID that will pay the transaction fees. This is typically the facilitator’s account; this account must also sign the transaction as the fee payer. -## PaymentPayload `payload` Field +### PaymentPayload `payload` Field The `payload` field of the `PaymentPayload` contains: @@ -89,7 +133,7 @@ Full `PaymentPayload` object: } ``` -## `SettlementResponse` +### `SettlementResponse` The `SettlementResponse` for the `exact` scheme on Hedera: @@ -105,11 +149,11 @@ The `SettlementResponse` for the `exact` scheme on Hedera: - `transactionId`: The Hedera transaction ID of the submitted transaction. - `payer`: The Hedera account ID of the fee payer that sponsored the transaction. -## Facilitator Verification Rules (MUST) +### Facilitator Verification Rules (MUST) A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of the following checks before sponsoring and signing the transaction. -### 1. Transaction layout +#### 1. Transaction layout - The decompiled transaction MUST be a `TransferTransaction` **directly**. It MUST NOT be wrapped in a `ScheduleCreateTransaction` or any other transaction type. - The transaction MUST: @@ -118,14 +162,14 @@ A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of t 3. Have the net sum of all HBAR transfers equal zero. 4. Have the net sum of all transfers for the specified `asset` equal zero. -### 2. Fee payer safety +#### 2. Fee payer safety - The configured `feePayer` (`PaymentRequirements.extra.feePayer`) MUST: - NOT appear as a **negative** entry in any HBAR transfer list. - NOT appear as a **negative** entry in the token transfer list for the specified `asset`. - The `feePayer` MAY appear as a positive entry (i.e., receive value), for example when collecting fees or custom fee distributions, but it MUST NOT be the net sender of funds in the payment transaction; it only sponsors network fees via `transactionId.accountId`. -### 3. Network and asset correctness +#### 3. Network and asset correctness - The `network` field in `PaymentRequirements` MUST be a valid Hedera CAIP-2 network identifier corresponding to the Hedera network on which the transaction will be submitted (e.g. `hedera:mainnet`, `hedera:testnet`). - The `asset` in `PaymentRequirements` MUST be: @@ -133,7 +177,7 @@ A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of t - A valid fungible token ID for an HTS fungible token. - All token transfers in the transaction MUST be for the single `asset` specified in `PaymentRequirements.asset`. No other token IDs may appear. -### 4. Transfer intent and destination +#### 4. Transfer intent and destination - The transaction MUST transfer value from the client’s account(s) to the `payTo` account specified in `PaymentRequirements.payTo`. - For HBAR payments: @@ -141,7 +185,7 @@ A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of t - For HTS FT payments: - The net token amount credited to `payTo` for `asset` MUST equal `PaymentRequirements.amount`. -### 5. Amount exactness +#### 5. Amount exactness - The `amount` transferred to `payTo` for the given `asset` MUST equal `PaymentRequirements.amount` **exactly**. - No additional positive net transfers to any other party (besides `payTo`) may exist for the specified `asset`. @@ -149,7 +193,7 @@ A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of t - The net amount to `payTo` is not exactly equal to `PaymentRequirements.amount`, or - The client is sending more than `PaymentRequirements.amount` in total for the specified `asset`. -### 6. Payer signature, general validity, and replay protection +#### 6. Payer signature, general validity, and replay protection - The facilitator MUST verify that the inferred payer actually signed the frozen transaction body before sponsoring it. The facilitator fetches the payer's onchain account key (e.g. via a consensus-node `AccountInfoQuery`) and checks that the transaction carries a valid signature satisfying that key, including KeyList/threshold accounts. A transaction signed with the wrong key, or left unsigned, MUST be rejected (reason `invalid_exact_hedera_payload_signature_invalid`). Without this check a payload that fails at settlement with `INVALID_SIGNATURE` would otherwise pass verification. - The transaction MUST: @@ -160,10 +204,455 @@ A facilitator verifying an `exact`‑scheme Hedera payment MUST enforce all of t These checks are security‑critical to ensure the fee payer cannot be tricked into transferring their own funds or sponsoring unintended actions. Implementations MAY introduce stricter limits (e.g., additional policy around max fee, max amount, or allowed token lists) but MUST NOT relax the above constraints. -### Account aliases and auto-account creation +#### Account aliases and auto-account creation When the resource server’s `payTo` is specified as an **account alias** (e.g. an EVM address or public key alias) rather than an existing account ID, a transfer of HBAR to that alias can trigger **auto-account creation** on Hedera. In that case, the facilitator effectively funds the creation of the new account (the first transfer to the alias creates the account and credits it). A malicious or poorly configured resource server could use this to have facilitators pay for account creation on its behalf. This specification does **not** require facilitators to forbid such transfers. Facilitators MAY handle this in whatever way they see fit: for example, they MAY require that `payTo` resolve to an existing account and reject transactions that would trigger auto-account creation, or they MAY allow it and accept the cost. Implementations SHOULD document their policy and, if they allow transfers to aliases, consider the associated cost and abuse potential. +## AssetTransferMethod: `transferExecutor` + +This asset transfer method lets a Client whose funds are controlled by a smart contract pay an +`exact` quote. It is particularly suited for contract accounts (smart accounts, multisig, treasury +and vault contracts) and for accounts that have delegated spending to a contract through a HIP-336 +allowance (delegation, session-key, spend-policy, subscription and agent-wallet contracts). Such +payers cannot sign a `TransferTransaction` for the funds in question: the contract's logic moves +them, and the transaction that triggers it is a `ContractExecuteTransaction`. + +The mechanism it relies on is native to Hedera: + +- HIP-336 lets an account grant an HBAR or HTS allowance to **a contract**, and HIP-376 exposes + `transferFrom` on the HTS ERC-20 facade, where the amount is deducted from **the caller's** + allowance, the caller being the contract. +- A contract that holds funds itself moves them through the HTS system contract + (`cryptoTransfer`, `transferToken`) or a native value transfer. +- Transfers initiated by a contract appear in the **child records** of the + `ContractExecuteTransaction`, so their outcome is observable from consensus. + +The Client declares which contract acts for it and supplies an opaque authorization; the Facilitator +constructs the transfer call itself, so the asset, recipient and amount in the call are always the +Facilitator's own values, never the Client's. + +The executor is foreign code the facilitator calls from its own account, so this method takes the +same controls as the `custom` operator type of `auth-capture` on EVM: default-deny admission, a +facilitator-chosen gas limit, and an outcome read from the ledger. + +What this method changes relative to `cryptoTransfer`: + +- **Payer universe.** Any account whose funds are held by, or spendable through, a contract on the + same network. The payer's account key never signs a Hedera transaction. +- **Authorization carrier.** The Client's authorization travels as opaque bytes in whatever form the + executor contract defines (an EIP-712 signed intent, a session-key signature, an on-chain multisig + approval id, a standing policy). The facilitator never interprets it. +- **Who moves funds.** The executor contract, not the payer and not the facilitator. The facilitator + only signs the wrapping `ContractExecuteTransaction` and pays its fee. +- **Verification model.** Simulation before settlement and consensus-record verification after, + instead of transfer-list inspection and payer-signature checks. +- **Replay protection.** Enforced by the executor contract, which consumes a single-use + authorization (Executor interface), not by Hedera transaction ids. +- **`extra.feePayer` is not used.** The Client builds no Hedera transaction, so it does not need the + fee payer's identity. The facilitator submits from an account selected from its own configuration. + Executors that want submitter binding MAY check `msg.sender` against a value the Client places in + `authorization`; that is executor-specific and out of scope here. + +The Client chooses the executor from those the facilitator admits, as `Executor admission` below +sets out. The fixed interface, the simulation and the settlement checks bound what an admitted +executor can do with the call. + +### Executor interface + +An executor MUST implement: + +```solidity +interface ITransferExecutor { + /// Moves exactly `amount` of `asset` from `from` to `to` if, and only if, + /// `authorization` permits it. MUST revert otherwise. Any returned data is ignored. + /// `asset == address(0)` denotes HBAR; otherwise the HTS token's EVM address. + function executeTransfer( + address from, + address asset, + address to, + uint256 amount, + bytes calldata authorization + ) external; +} +``` + +Requirements on an executor: + +- It MUST bind `authorization` to `from`, `asset`, `to` and `amount` (or to values that imply them), + so a facilitator cannot reuse an authorization for a different transfer, and MUST revert on any + mismatch. +- An authorization MUST be single-use: a successful `executeTransfer` MUST consume it so any later + call presenting the same `authorization` reverts, and a reverted call MUST NOT consume it. A + standing spend policy is still permitted, one single-use authorization per payment. +- It MUST signal failure by reverting. Returned data is ignored, so returning `false` is not a + failure signal: a `SUCCESS` receipt with no transfer is a failed settlement (Phase 4). +- It MUST NOT depend on `msg.value` and MUST NOT debit any account other than `from`. +- Funds move either from the executor's own balance, where `from` is the executor's account, or via + a HIP-336 allowance from `from` to the executor. The account debited MUST be `from`; settlement + rule 3 fails any other debit. + +`executeTransfer` is the only function a facilitator calls. Everything else about the executor +(how authorizations are created, policies, key management) is opaque to x402. + +The interface name is NOT normative: conformance is by function signature, whose selector is +`0xea8f19fd`, which does not encode return types, so a contract that returns data still conforms. +The interface is deliberately protocol-agnostic, so a contract that already exposes it for other +callers needs no x402-specific surface. + +### Executor admission + +`payload.executor` is Client-chosen contract code the facilitator calls from its own account, so +admission is default deny: a facilitator MUST reject an executor absent from the `extra.executors` +list it advertises for that network with `invalid_exact_hedera_executor_not_admitted`, at +verification and again before submitting. An omitted or empty list admits nothing and MUST NOT be +read as a wildcard; `"*"` admits every contract, MUST be written out, and is NOT RECOMMENDED. +Admission is by address, not by code, so a facilitator SHOULD prefer an executor that cannot change +under it (no `admin_key`, no upgrade path) and MUST apply the Phase 4 checks on every settlement. A +facilitator that cannot read a settled transaction's child records and logs MUST NOT advertise +`transferExecutor`. + +A resource server offering `transferExecutor` MUST carry `extra.executors` in its +`PaymentRequirements`, taken from the `extra.executors` its facilitator advertises for that +`network` in `/supported`, and a Client MUST pick `payload.executor` from that list. A server MAY +narrow the list and MUST NOT widen it, and MAY publish `"*"` only where the facilitator advertises +`"*"`. An omitted or empty list admits nothing. The facilitator judges `payload.executor` against +its own list and MUST reject an executor absent from it, whatever the requirements named. + +### Prerequisites + +1. **Executor contract.** A contract implementing `ITransferExecutor` deployed on `network`. +2. **Funding path.** Either `payer` is the executor's own account and holds the funds, or `payer` + has granted the executor a HIP-336 allowance for `asset` covering `amount`. +3. **Association.** For HTS assets, `payTo` MUST be associated with `asset` or hold a free + auto-association slot. +4. **Addressability.** `payer`, `payTo` and `executor` MUST resolve to EVM addresses. Facilitators + resolve Hedera ids through the mirror node and MUST use an account's alias address where one + exists; the long-zero form of an alias-bearing account does not resolve in HTS calls. +5. **Client authorization.** The Client is able to produce an `authorization` the executor accepts + for this exact transfer. +6. **No custom fees on `asset`.** A fixed or fractional custom fee credits fee collectors and makes + the credit to `payTo` differ from the debit to `payer`, which `exact` cannot express. The + facilitator MUST read the token's fee schedule at verification and reject with + `invalid_exact_hedera_custom_fee_asset`. + +### Phase 1: Obtaining an authorization + +How the Client obtains the authorization is outside the scope of x402. Examples: + +- signing an EIP-712 intent that the executor verifies on chain; +- a session key or delegated key permitted by the executor's policy; +- a multisig or governance proposal already approved on chain, referenced by id; +- a pre-configured spend policy the executor enforces, one single-use authorization per payment. + +Where the authorization embeds an expiry, Clients SHOULD bound it by `maxTimeoutSeconds` so the +on-chain window and the resource server's willingness to wait cannot drift apart. + +### Phase 2: `PAYMENT-SIGNATURE` Header Payload + +`PaymentRequirements` for `transferExecutor`: + +```json +{ + "scheme": "exact", + "network": "hedera:testnet", + "amount": "100000000", + "asset": "0.0.0", + "payTo": "0.0.4001", + "maxTimeoutSeconds": 120, + "extra": { + "assetTransferMethod": "transferExecutor", + "executors": ["0.0.7000"] + } +} +``` + +**`extra` field definitions specific to `transferExecutor`:** + +- `extra.assetTransferMethod` (required): MUST be `"transferExecutor"`. +- `extra.executors` (required): the executors this server accepts, mirrored from the facilitator's + `/supported` and optionally narrowed (Executor admission). The Client picks `payload.executor` + from it, so a quote that omits the field cannot be paid. +- `extra.feePayer` (optional): ignored by this method. Resource servers that serve both methods + from one `accepts[]` entry MAY leave it in place for `cryptoTransfer` clients. + +The `payload` field must contain: + +- `payer`: Hedera account id (`0.0.x`) whose funds are debited, passed as `from`. MUST be the + account the settlement record shows debited, so a vault holding the funds itself is named here. +- `executor`: Hedera contract id (`0.0.x`) of the `ITransferExecutor` acting for the payer. +- `authorization`: `0x`-prefixed hex, opaque bytes the executor validates. + +Full `PaymentPayload` object: + +```json +{ + "x402Version": 2, + "resource": { + "url": "https://example.com/weather", + "description": "Access to protected content", + "mimeType": "application/json" + }, + "accepted": { + "scheme": "exact", + "network": "hedera:testnet", + "amount": "100000000", + "asset": "0.0.0", + "payTo": "0.0.4001", + "maxTimeoutSeconds": 120, + "extra": { + "assetTransferMethod": "transferExecutor", + "executors": ["0.0.7000"] + } + }, + "payload": { + "payer": "0.0.5001", + "executor": "0.0.7000", + "authorization": "0x0f2c9d1a..." + } +} +``` + +Nothing else crosses the wire: no serialized Hedera transaction, no fee-payer signature, no +transfer calldata. + +### Phase 3: Verification Logic + +Unlike `cryptoTransfer`, `transferExecutor` verification rests on admission and simulation. The +`authorization` is opaque to the facilitator but verifiable by simulating the intended call. + +A facilitator that understands a specific authorization format MUST define a separate +`assetTransferMethod` for it. Within `transferExecutor` that knowledge MAY narrow what is accepted +(caps, per-payer limits) but MUST NOT replace the simulation, which is the only check that covers +conditions the facilitator cannot enumerate. + +The facilitator: + +1. **Validates shape.** `extra.assetTransferMethod == "transferExecutor"`; `payload.payer` and + `payload.executor` are well-formed Hedera ids; `payload.authorization` is `0x`-prefixed hex. + Network and asset rules of `cryptoTransfer` (rule 3) apply unchanged. Reject with + `invalid_payload` / `invalid_payment_requirements`. An HTS `asset` with any custom fee is + rejected with `invalid_exact_hedera_custom_fee_asset` (Prerequisite 6). +2. **Checks method support.** It implements `transferExecutor` on `network`; otherwise + `invalid_exact_hedera_unsupported_asset_transfer_method`. +3. **Checks executor admission.** `payload.executor` is admitted on `network`; otherwise + `invalid_exact_hedera_executor_not_admitted` (Executor admission). +4. **Resolves addresses.** `payer` and `payTo` to EVM addresses (Prerequisite 4), rejecting with + `address_resolution_failed` when either cannot be resolved; `asset` to `address(0)` for + `"0.0.0"`, else the token's long-zero address. `executor` stays a Hedera contract id: contracts + are addressable by their long-zero form, so no lookup is needed. +5. **Constructs** the call + `executeTransfer(from = payer, asset, to = payTo, amount, authorization)`. The facilitator MUST + build these arguments from `PaymentRequirements` and `payload.payer` only; it MUST NOT accept a + client-supplied calldata blob. +6. **Simulates** that call against `executor` from the account it will submit from, to verify: + - the authorization is valid, unconsumed and permits exactly this transfer; + - the payer has sufficient balance / allowance; + - the transaction will succeed when executed. + The facilitator MUST choose the gas limit and MUST simulate with the limit it will submit with. + It MUST reject on revert (`simulation_reverted`). A facilitator that can measure simulated usage + SHOULD reject an unexpectedly high figure (`simulation_gas_exceeded`), which may indicate an + executor designed to drain the fee payer; where only the ceiling is enforced, an over-budget call + surfaces as a revert. The simulation channel MUST execute state-changing calls, including calls + into the HTS system contract; a read-only channel reports reverts that settlement would not + produce. + +If the simulation succeeds, the payment is valid. A passing simulation MUST NOT be +reported as settlement and MUST NOT cause the resource server to release the resource; state can +change between `/verify` and `/settle`, and the settlement checks (Phase 4) are the only evidence of +payment. + +The facilitator SHOULD read state that gates the call (nonces, allowances) from a consensus or +mirror node rather than a JSON-RPC relay, which may serve slightly stale state. + +Because an authorization is single-use, a passing simulation also establishes that the payload is a +new payment: one that already settled is consumed and simulates as a revert, so nothing has to be +remembered about settled payloads. Only the window between `/verify` and `/settle` stays open, where +two copies of one payload both simulate cleanly, so a facilitator SHOULD reject a payload identical +to one it already has in flight, keyed on the hash of +`(network, executor, payer, authorization)`. + +Facilitators MAY introduce stricter limits (max amount, allowed assets, per-payer rate limits) but +MUST NOT relax the above constraints. + +This method does not require the outcome to be asserted before relaying, as `auth-capture`'s +`custom` type does: Hedera exposes no `debug_traceCall` and the mirror node's +`POST /api/v1/contracts/call` returns only the call's return data, so no simulation channel reports +movements (hiero-ledger/hiero-json-rpc-relay#5113). Admission is the preventive control and Phase 4 +asserts the outcome; a facilitator that gains that visibility SHOULD assert it at verification too. + +### Phase 4: Settlement Logic + +1. The facilitator builds a `ContractExecuteTransaction` with `contractId = executor`, + `functionParameters = executeTransfer(from, asset, to, amount, authorization)` exactly as + simulated, the gas limit it simulated with and `transactionId.accountId` set to an account from + its own configuration; signs it as fee payer; submits it. It MUST re-check that `executor` is + still admitted (Executor admission) before submitting, and MUST only ever sign this wrapping + transaction. +2. After consensus the facilitator MUST fetch the transaction record **including child records** + (transfers made by a contract appear in child records, not in the parent) and merge, across + parent and children, every transfer list the record carries: HBAR, each token, and NFTs. It MUST + also read the contract result's logs, including those of nested calls. +3. The facilitator MUST report `success: false` unless all of the following hold: + - the net credit to `payTo` in `asset` equals `amount` exactly; + - the positive net changes in `asset` other than `payTo`'s sum to the record's `transactionFee`. + Unlike `cryptoTransfer`, this runs on a consensus record, which always credits the node, fee + collection and staking accounts; for HBAR those credits share the payment's list. An HTS token + list carries no fee distribution, so there `payTo` MUST be the only credit; + - the only debited accounts are `payer` (by `amount` in `asset`) and the fee payer (by the + transaction fee in HBAR). Any third debited account MUST fail the settlement, so a hostile + executor cannot touch an account nobody expected. When `payer` is the fee payer, the single HBAR + debit MUST equal `amount + transactionFee` and the facilitator reports the two parts separately + using the record's `transactionFee`; + - no account the facilitator controls is debited beyond the fee payer's transaction fee, and no + log records a transfer out of such an account, which is where a non-HTS token movement appears + (`settlement_unexpected_transfer`); + - no transfer list other than `asset`'s carries anything beyond the HBAR fee distribution, and no + NFT changes owner (`settlement_unexpected_transfer`). +4. A transaction that reaches consensus with a `SUCCESS` receipt but whose record does not satisfy + rule 3 is a **failed settlement**. Settlement success MUST reflect actual on-chain effects, not + the receipt status and not simulation. +5. If the transaction broadcasts successfully but its record cannot be obtained (node or mirror + error, timeout), the facilitator MAY return `settlement_pending` with `success: false` and the + transaction id in `transaction`, so the caller can reconcile on chain before retrying. + +`SettlementResponse` (core v2 shape, `transaction` carries the Hedera transaction id): + +```json +{ + "success": true, + "transaction": "0.0.9000@1755500000.000000000", + "network": "hedera:testnet", + "payer": "0.0.5001" +} +``` +- `payer`: the account debited `amount`. + +### Error codes + +`invalidReason` / `errorReason` are stable tokens. Standard v2 codes (`insufficient_funds`, +`invalid_payload`, `invalid_payment_requirements`, `invalid_scheme`, `invalid_network`, +`invalid_x402_version`, `unexpected_verify_error`, `unexpected_settle_error`, `settlement_pending`) +apply as usual. Method-specific values: + +| Code | Meaning | +| --- | --- | +| `invalid_exact_hedera_asset_transfer_method` | `extra.assetTransferMethod` is not a value this mechanism defines | +| `invalid_exact_hedera_unsupported_asset_transfer_method` | the method is defined but this facilitator does not implement it on `network` | +| `invalid_exact_hedera_executor_not_admitted` | `payload.executor` is not an executor this facilitator admits on `network` (Executor admission) | +| `invalid_exact_hedera_payload_transfer_executor` | `payload` is absent or not an object | +| `invalid_exact_hedera_payload_payer` | `payload.payer` missing or not a Hedera account id | +| `invalid_exact_hedera_payload_executor` | `payload.executor` missing or not a Hedera contract id | +| `invalid_exact_hedera_payload_authorization` | `payload.authorization` not `0x`-prefixed hex | +| `invalid_exact_hedera_custom_fee_asset` | `asset` has a custom fee schedule, so `exact` cannot hold (Prerequisite 6) | +| `address_resolution_failed` | `payer` or `payTo` could not be resolved to an EVM address | +| `simulation_reverted` | `executeTransfer` reverted in simulation | +| `simulation_gas_exceeded` | OPTIONAL; simulated gas above the facilitator's bound | +| `settlement_status_not_success` | the record's status is not `SUCCESS` | +| `settlement_payee_credit_mismatch` | the record shows a net credit to `payTo` that is not `amount` | +| `settlement_payer_debit_mismatch` | the payer's net debit is not the expected figure | +| `settlement_fee_payer_debit_mismatch` | the fee payer's net debit is not the transaction fee | +| `settlement_unexpected_debit` | an account other than `payer` and the fee payer lost funds | +| `settlement_unexpected_transfer` | the record carries a movement the payment does not account for: another token, an NFT, or a debit of a facilitator-controlled account | +| `settlement_check_failed` | the settlement checks failed for a reason with no more specific code | + +A resource server that refuses a payment on its own policy, for example an executor outside a local +allowlist, reports it in the 402 body's `error` field (`executor_not_allowed`), which is its own +vocabulary and distinct from the facilitator's `invalidReason`. + +### Security Considerations + +1. **Fee payer safety.** The executor spends from `from` (allowance or own balance), never from the + sender of the wrapping transaction, so the fee payer's balances and allowances are never + referenced by the call it signs, and the gas limit (Consideration 4) bounds the fee. Admission + (Executor admission) keeps the relay out of contract code the facilitator never reviewed. + Settlement rule 3 makes any other debit, in any transfer list or log, a failed settlement, but + only after consensus, when the fee is already spent. +2. **Authorization scope.** The facilitator constructs `from`, `asset`, `to` and `amount` itself + and cannot resize or redirect the payment; the executor decides only whether the authorization + permits that exact transfer. The executor is responsible for bounding what an authorization may + spend (caps, recipient restrictions); the facilitator will not. +3. **Race condition between verify and settle.** A Client (or a concurrent payment) may invalidate the + authorization, consume a nonce or drain the balance between simulation and execution, causing the + facilitator to pay gas for a failed call. Mitigations: re-simulate immediately before submission, + per-payer and per-executor rate limits, reputation signals, restricting which resource servers may + request settlement. +4. **Malicious executor gas consumption.** An executor may behave differently at execution than at + simulation or attempt to consume excessive gas. Facilitators MUST choose the gas limit themselves + and use the same one for simulation and submission, MUST admit executors by address, and SHOULD + reject unexpectedly high simulated gas. Resource servers MAY narrow the set further. +5. **Post-settlement verification (TOCTOU).** Simulation proves a call *would* succeed, not that it + *did*. The record-based checks in Phase 4 are mandatory precisely because a receipt of `SUCCESS` + is compatible with no funds moving. +6. **Replay.** On chain: the executor consumes the single-use authorization, the only authoritative + defence. Off chain: nothing needs to be remembered after settlement, because a consumed + authorization can no longer simulate; the facilitator's in-flight guard covers only the window + between verify and settle (Phase 3). Resource servers SHOULD refuse to serve twice for one + `transaction`. +7. **Settlement atomicity.** One transaction moves the funds; a revert rolls everything back. Soft + failure (no revert, no transfer) is detected by record, never by receipt. +8. **Account aliases.** The alias / auto-account-creation policy of `cryptoTransfer` applies to + `payTo` credits produced by the executor. +9. **Relaying into foreign code.** An executor can pay and, in the same call, do something else, + which the ledger attributes to the facilitator as `transactionId.accountId`. Admission prevents, + Phase 4 detects; with no trace-capable simulation (Phase 3) detection lands after the fee is + spent, so a facilitator unwilling to carry that MUST NOT advertise the wildcard. + +Security invariants, in the style of the SVM spec. A rule that reads a consensus record can only +report a violation that already happened, so prevention and detection are listed separately: + +| ID | Invariant | Prevented by | Detected by | +| --- | --- | --- | --- | +| I1 | Fee payer is never debited beyond the network fee | Executor spends from `from`, never from the wrapping transaction's sender; the fee payer grants it no allowance; facilitator-chosen gas limit; admission (Considerations 1 and 4) | Settlement rule 3, over every transfer list and the logs | +| I2 | `payTo` is credited exactly `amount` of `asset` | Facilitator-built call (verification rule 5); assets with custom fees rejected at verification (Prerequisite 6) | Settlement rule 3 | +| I3 | Only `payer` is debited `amount`, and nothing else moves | Facilitator-built call; executor MUST NOT debit any account other than `from`; admission | Settlement rule 3, over every transfer list and the logs | +| I4 | Settlement success equals on-chain effect | n/a; this invariant is the detection itself | Settlement rules 2 to 4 | +| I5 | Simulation is a viability and freshness check, not the security boundary | n/a; a statement of the model, see Phase 3 | n/a | +| I6 | The facilitator cannot resize or redirect the payment | Verification rule 5 | Settlement rule 3 | +| I7 | The facilitator relays only into executors it admitted | Default-deny admission, re-checked before submitting (verification rule 3, settlement rule 1) | n/a | +| I8 | One authorization settles at most one payment | Executor consumes it on success (Executor interface); in-flight guard between verify and settle | Simulation reverts once consumed | + +### Comparison to `cryptoTransfer` + +| | `cryptoTransfer` | `transferExecutor` | +| --- | --- | --- | +| Payer signs | The Hedera transaction | Nothing on chain; authorization is opaque bytes | +| Funds move via | Transfer list | Executor contract (allowance or own balance) | +| Who fixes asset / recipient / amount | Client, checked by facilitator | Facilitator, in the call it builds | +| Facilitator verifies | Transfer list + payer signature | Admission + simulation + consensus-record checks | +| Replay protection | Hedera transaction id | Single-use authorization, consumed by the executor | +| `extra.feePayer` | Required | Not used | +| Executor choice | n/a | Client, among the executors the requirements publish | +| Fee payer exposure | Fee only (rule 2) | Fee only (settlement rule 3) | +| Payer universe | Key-controlled accounts | Contract-controlled funds | + +## Implementer Notes + +- Client SDKs SHOULD expose a hook on the signer that produces `{ executor, authorization }` from + `PaymentRequirements`, so the mechanism package stays free of any executor internals. +- Executor authors SHOULD enforce expiry before consuming any nonce so an expired authorization + leaves the payer able to re-sign, and SHOULD emit an event carrying `(from, asset, to, amount)` for + off-chain reconciliation. +- Existing contracts with their own entry points can adopt this method with a thin + `executeTransfer` adapter that decodes `authorization`, checks it against the supplied arguments + and delegates to the existing logic. + +## Annex: Reference executors + +`transferExecutor` does not define a canonical executor. Any `ITransferExecutor` works. Known +implementations: + +- **Hedera Agentic Accounts `PaymentRouter`**: an owner grants the contract a HIP-336 allowance and + signs a policy (agent key, recipient allowlist, per-asset caps); an agent signs a per-payment + EIP-712 intent (`asset, source, destination, value, intentNonce, validAfter, validBefore, context`) + with `validBefore` derived from `maxTimeoutSeconds`. `authorization` is + `abi.encode(signedConfig, signedIntent, merkleProof)`; `executeTransfer` checks + `intent.source == from`, `intent.asset == asset`, `intent.destination == to`, + `intent.value == amount`, then runs the router's policy checks and transfer, reverting on any + failure. `intentNonce` makes each authorization single-use. It is an upgradeable diamond, so + admission rests on the Phase 4 checks rather than on immutability. HBAR is exercised end to end; + HTS by contract and unit tests. +- Other natural fits: a multisig or vault contract executing an already-approved transfer by id; a + subscription contract releasing a due payment; a session-key wallet. diff --git a/specs/schemes/exact/scheme_exact_lnbtc.md b/specs/schemes/exact/scheme_exact_lnbtc.md new file mode 100644 index 0000000000..0324b6fae9 --- /dev/null +++ b/specs/schemes/exact/scheme_exact_lnbtc.md @@ -0,0 +1,760 @@ +# Scheme: `exact` on Bitcoin Lightning (`lnbtc`) + +## Summary + +This document specifies the x402 `exact` payment scheme for Bitcoin Lightning +networks using the `lnbtc` namespace defined here, in CAIP-2 format. The client +pays a fresh BOLT11 invoice from the resource server and returns its 32-byte +payment preimage. The facilitator verifies `SHA-256(preimage) == payment_hash` +and requires the invoice signing key to match `payTo`. This check does not require +access to the receiver's Lightning node. A client MUST NOT select this scheme +unless its payer Lightning node returns the preimage after payment. + +The scheme supports only the `bolt11` asset transfer method and the `upfront` +payment flow. The resource server processes the request only after the facilitator +validates the proof and records the payment hash. + +This scheme targets x402 protocol version 2 and uses the core +`PaymentRequirements`, `PaymentPayload`, and `SettlementResponse` types from +[x402-specification-v2.md](../../x402-specification-v2.md). + +## Scheme and Networks + +The `lnbtc` namespace identifies Bitcoin Lightning networks. Its reference MUST +be the first 32 characters of the underlying Bitcoin network's genesis block +hash in lowercase hexadecimal, following the +[BIP-122 CAIP-2 reference convention](https://namespaces.chainagnostic.org/bip122/caip2). +The supported networks are listed below. + +- `scheme` MUST be `"exact"`. +- `asset` MUST be `"BTC"`. +- Mainnet MUST use + `lnbtc:000000000019d6689c085ae165831e93` and BOLT11 currency `bc`. +- Bitcoin testnet MUST use + `lnbtc:000000000933ea01ad0ee984209779ba` and BOLT11 currency `tb`. +- Messages on the wire MUST use one of these concrete network identifiers. + +## Asset Transfer Method and Payment Flow + +`"bolt11"` is the only supported asset transfer method. +`extra.assetTransferMethod` MAY be omitted, in which case it defaults to +`"bolt11"`. Any explicit value MUST be `"bolt11"`. + +`"upfront"` is the only supported payment flow because a Lightning payment settles +before its preimage is available. The resource server MUST set +`extra.paymentFlow` to `"upfront"` in every payment requirement. Clients and +resource servers MUST reject any other value. + +The request hash is the SHA-256 digest of a JSON object encoded with the JSON +Canonicalization Scheme (JCS, RFC 8785). It covers a versioned transport profile +and that profile's request inputs. The server sends it as `extra.requestHash` and +puts it in the invoice's signed BOLT11 description hash. See +[Request Binding](#request-binding) for the encoding. + +The protocol sequence is: + +1. The resource server computes the request hash and returns a fresh BOLT11 + invoice that commits to it in a payment requirement. +2. The client validates and pays the invoice, then constructs a payment payload + containing the preimage. +3. The resource server sends the payload to the facilitator's `/settle` endpoint. + It MUST NOT call `/verify` for this flow. +4. The facilitator validates the proof and atomically records the payment hash as + used. +5. The resource server processes the protected request only after `/settle` + succeeds. + +## Terminology and Adapter Requirements + +- **Receiver**: The seller-side Lightning node that creates invoices. +- **Payer**: The client-side Lightning node that pays an invoice. +- **Payment hash**: The 32-byte value committed to by the BOLT11 invoice. +- **Preimage**: The 32-byte secret whose SHA-256 digest is the payment hash. +- **Replay store**: Storage that atomically records settled network and payment-hash + pairs and persists across facilitator restarts. + +A payer adapter MUST return the payment preimage when it reports a payment as +paid. A client MUST NOT use an adapter that cannot return the preimage. + +A receiver adapter MUST be able to create a fresh invoice for an exact +millisatoshi amount with a caller-supplied BOLT11 description hash. The resource +server MUST have exclusive invoice-issuance authority for the receiver key in +`payTo`; an untrusted party MUST NOT be able to create invoices signed by that key. + +## Amounts + +`amount` MUST be a decimal string that encodes a positive integer number of +millisatoshis. It MUST NOT contain a sign, decimal point, exponent, separator, or +unit suffix. The BOLT11 invoice MUST specify an integral millisatoshi amount. + +Examples: + +| Meaning | Wire `amount` | +|---|---:| +| 1 millisatoshi | `"1"` | +| 1 satoshi | `"1000"` | +| 21 satoshis | `"21000"` | +| 1 bitcoin | `"100000000000"` | + +User-facing SDKs MUST use explicit atomic `AssetAmount` pricing by default, for +example `{ "asset": "BTC", "amount": "21000" }` for 21 satoshis. They MAY also +accept qualified forms such as `"21 sat"` or `"21 sats"`. Parsers MUST use exact +decimal arithmetic and reject negative values and sub-millisatoshi precision. +Bare numeric prices such as `"21"` or `21` MUST NOT default to satoshis. These and +other forms, such as `$1`, `1 USD`, or `0.0001 BTC`, MUST be rejected unless the +application has registered a conversion parser. The error SHOULD direct the +caller to use an explicit atomic `AssetAmount`. + +For this method, the x402 settled amount is the invoice amount, which MUST equal +`PaymentRequirements.amount`. The preimage does not reveal the amount actually +received. Lightning can settle a payment above the invoice amount, as described +in [BOLT11](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md#payer--payee-interactions). +The facilitator MUST accept an otherwise valid proof at the invoice amount even +if Lightning overpayment occurred. Overpayment grants no additional resource or +credit. This scheme provides no refund path; any refund is a separate arrangement +with the receiver. Clients MUST NOT assume a refund is available. + +Clients MUST instruct their payer adapter to pay the invoice amount. Routing fees +are paid by the payer in addition to that amount and do not count toward it. The +receiver MUST reject payments whose aggregate amount is below the invoice amount +without releasing the preimage. An incomplete or failed payment MUST NOT +authorize the resource. Its HTLCs follow Lightning failure or timeout handling; +there is no x402 refund transaction. + +## `PaymentRequirements` + +The resource server MUST generate a fresh BOLT11 invoice for each payment +challenge and place it in `extra.invoice`. A fresh invoice MUST use a new 32-byte +preimage generated with cryptographically secure randomness and a previously +unused payment hash, including across networks. The signed payment hash +identifies the invoice. + +The server MUST set `extra.requestBindingProfile` and +`extra.requestBindingParams` from its transport and resource configuration, and +`extra.requestHash` to the digest of the actual request, as specified below. + +The following deterministic example is a test vector, not a reusable challenge. +Its validation time is Unix timestamp `1700000000`, equal to the invoice creation +time. + +```json +{ + "scheme": "exact", + "network": "lnbtc:000000000019d6689c085ae165831e93", + "amount": "25000", + "asset": "BTC", + "payTo": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "bolt11", + "paymentFlow": "upfront", + "requestHash": "0d6623f775e025501fa7f0a30b54da25aad62b6ccfe35c85da38016711e6c018", + "requestBindingProfile": "http:1", + "requestBindingParams": { "headers": [] }, + "invoice": "lnbc250n1pj48ugqpp54y3u9s8ylemsv8l3ewyzzu0klhujvuvmkl6llchq23vy8rzjsf0qsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp5p4nz8am4uqj4q8a87z3sk4x6yk4dv2mvel34epw68qqkwy0xcqvqxqzfvcqpjr4rx6ls6j5rpwknuea64evlk7yfx56wmqcer5eerekdsn9tlv6v4ex9mlz5dtm9qapl3svwlqcf7837dmjkru9z9w4h2rvm0md52w2sqxrwu5f" + } +} +``` + +The `extra` fields are: + +| Field | Required | Meaning | +|---|---|---| +| `extra.assetTransferMethod` | No | If present, MUST be `"bolt11"`; defaults to `"bolt11"`. | +| `extra.paymentFlow` | Yes | MUST be `"upfront"`. | +| `extra.invoice` | Yes | Fresh, signed BOLT11 invoice for `amount` on `network` that passes the checks below. | +| `extra.requestHash` | Yes | Expected request hash, encoded as 64 lowercase hexadecimal characters. | +| `extra.requestBindingProfile` | Yes | Versioned request binding profile: `"http:1"` or `"mcp:1"`. | +| `extra.requestBindingParams` | Yes | Object containing exactly the parameters required by the selected profile. | + +`maxTimeoutSeconds` MUST be a positive integer. The BOLT11 invoice expiry MUST +equal `maxTimeoutSeconds` exactly. + +`payTo` MUST be the receiver node's valid compressed secp256k1 public key: exactly +33 bytes encoded as 66 lowercase hexadecimal characters without a prefix. The +invoice signing key MUST equal `payTo`. If the invoice contains an `n` field, that +field MUST equal `payTo`. Otherwise, the implementation MUST recover the key from +the invoice signature and require it to equal `payTo`. + +Before returning the challenge, the server MUST strictly decode the invoice and +verify all of the following: + +1. The BOLT11 amount equals `PaymentRequirements.amount` exactly. +2. The BOLT11 currency matches the concrete network (`bc` for mainnet, `tb` for + testnet). +3. The invoice's description hash equals the request hash. +4. The invoice signing key equals `payTo`. +5. The BOLT11 expiry equals `maxTimeoutSeconds` exactly. +6. The BOLT11 creation time is not later than the server's validation time plus + its configured non-negative clock-skew allowance, whose default MUST be 60 + seconds. Equality at this boundary is valid. +7. The invoice has not expired at the server's validation time. + +The server MUST NOT reuse an invoice across clients or challenges. + +`extra.invoice` is dynamic. Scheme implementations MUST declare `invoice` as a +dynamic `extra` field. `requestHash`, `requestBindingProfile`, and +`requestBindingParams` MUST NOT be dynamic. The server MUST derive them from the +actual request and its configuration before matching requirements. Every core +field and every server-declared `extra` field +except `invoice` MUST match the accepted requirements. This permits a paid retry +to use its original invoice for the same request. + +## Request Binding + +The BOLT11 description hash MUST equal the request hash defined below. Clients, +servers, and facilitators MUST reject invoices without this binding. The invoice +signature covers both the description hash and the unique payment hash. Identical +requests can have the same request hash while their invoices have distinct payment +hashes. + +The payment hash identifies the invoice, not a particular request attempt. +An unused proof MUST be accepted for another challenge with the same request +binding and payment terms if all validation rules pass. Servers need not track +issued payment hashes per challenge. + +The server MUST select the profile for the operation being purchased. MCP tool +calls MUST use `mcp:1`, including when MCP uses an HTTP transport. Other HTTP +requests use `http:1`. Clients, servers, and facilitators MUST reject missing, +unknown, or unsupported profiles and malformed profile parameters; they MUST NOT +fall back to another profile. Other transports require a separately defined +profile before they can use this method. + +The domain tag MUST be `"x402:exact:lnbtc:bolt11:"` concatenated with +`extra.requestBindingProfile`. Clients and servers MUST construct the profile's +binding object from the actual request. The server MUST reject requests whose +purchased operation, content interpretation, or account selection depends on +context not represented by that object. It MUST apply its normal authentication +and authorization checks on every attempt. + +### Description Hash Encoding + +The description bytes MUST be the UTF-8 output of +[JCS (RFC 8785)](https://www.rfc-editor.org/rfc/rfc8785.html) applied to the +profile's binding object: + +```text +descriptionBytes = UTF8(JCS(binding)) +requestHash = SHA-256(descriptionBytes) +``` + +Implementations MUST use JCS member sorting, string escaping, and number +serialization, and reject inputs outside its data model, including duplicate +object member names and invalid Unicode. No byte-order mark or trailing newline +is part of the hash input. The binding object is constructed locally; it is not +an additional field in `PaymentRequirements` or `PaymentPayload`. + +The BOLT11 description hash MUST contain the raw 32-byte `requestHash` digest. +`extra.requestHash` MUST contain that same digest as 64 lowercase hexadecimal +characters. The client reconstructs the description bytes from its request and +the validated profile parameters without another server lookup. + +The invoice signature also commits to its amount, currency, expiry, and payment +hash; the signer identifies the receiver. The domain tag fixes the x402 version-2 +`exact`/`bolt11`/`upfront` interpretation. The existing payment-term checks remain +mandatory. Neither the invoice nor `extra.requestHash` is part of the hash input. + +On a paid retry, the server MUST recompute the digest from the request that will +execute, using its configured profile and parameters. It MUST NOT take the +expected digest or profile parameters from `accepted.extra`, +`PaymentPayload.resource`, or the accepted invoice. It sends the computed digest +in `requirements.extra.requestHash` to `/settle`. The facilitator MUST compare +the accepted invoice's signed description hash against that expected digest. +Missing binding fields MUST fail rather than disable the check. A previous +successful claim remains `duplicate_settlement`. + +### HTTP Profile (`http:1`) + +`extra.requestBindingParams` MUST contain exactly one member, `headers`, whose +value is the configured array of bound header names. The client and server +compute the binding from the HTTP request itself. + +- `method` and `url` MUST use the `@method` and `@target-uri` component rules in + [RFC 9421, section 2.2](https://www.rfc-editor.org/rfc/rfc9421.html#section-2.2). + The URL MUST be an absolute `http` or `https` URL in ASCII URI syntax, including + the query string, without a fragment or user information. Implementations MUST + preserve method case, percent escapes, and query parameter order. The server + MUST validate the public origin against its configuration and reconstruct it + only from trusted proxy information when behind a proxy. +- `bodyHash` MUST be SHA-256 of the content bytes after transfer decoding and + before content decoding or application parsing. An absent body uses SHA-256 of + empty bytes. JSON bodies MUST NOT be parsed and serialized before hashing. The + paid retry MUST preserve the content bytes. +- Bound headers MUST be selected by the server's configuration for the resource, + never from the client echo. `extra.requestBindingParams.headers` MUST contain + every header that can affect the purchased operation, content interpretation, or account + selection, even when absent. Examples include `content-type`, `content-encoding`, + `accept`, `range`, `authorization`, and `cookie` when used for these purposes. + Names MUST be lowercase HTTP field-name tokens in ascending ASCII byte order, + with no duplicates. An empty array is valid only when no header affects these + decisions. `payment-signature` MUST NOT be included. + +For each present bound header, `valueHash` MUST be SHA-256 of +`0x01 || ASCII(value)`, where `value` uses the default field-component rules in +[RFC 9421, section 2.1](https://www.rfc-editor.org/rfc/rfc9421.html#section-2.1), +without component parameters. For an absent header it MUST be SHA-256 of the +single byte `0x00`. This distinguishes absent and empty values. Unsupported field +values MUST cause rejection. + +An account selected only through a TLS client certificate is an example of +unrepresented context and MUST cause rejection. Clients and servers MUST require +`PaymentRequired.resource.url` to equal `url`. + +The client and server MUST construct a JSON object, `binding`, with exactly these +members: + +| Member | Value | +|---|---| +| `domain` | The string `"x402:exact:lnbtc:bolt11:http:1"`. | +| `method` | The HTTP method defined above. | +| `url` | The HTTP target URL defined above. | +| `bodyHash` | The body digest defined above, encoded as 64 lowercase hexadecimal characters. | +| `headers` | An array with one object per name in `extra.requestBindingParams.headers`, in the same order. Each object has exactly `name` and `valueHash` members. `name` is the header name; `valueHash` is its digest defined above, encoded as 64 lowercase hexadecimal characters. | + +All members are required, including `headers` when empty. This object contains +only strings, arrays, and objects. JCS applies to this object; the HTTP body is +hashed as bytes as specified above. + +### MCP Profile (`mcp:1`) + +This profile covers `tools/call` in the [MCP transport](../../transports-v2/mcp.md). +Other MCP operations MUST NOT use this profile. `extra.requestBindingParams` +MUST contain exactly `server` and `metadata`: + +- `server` MUST be an absolute URI in ASCII syntax, without user information or + a fragment, identifying the MCP server. For HTTP connections it MUST be the + public MCP endpoint URI; for other connections it MUST be a URI agreed through + client and server configuration. Both sides MUST use the exact configured + spelling. The client MUST check this value against its intended server's + connection or trusted configuration, not learn its identity from the payment + challenge. The server MUST use its own configuration, not the client echo. +- `metadata` MUST be an array of non-empty `params._meta` member names, sorted + according to JCS property ordering, without duplicates. Names are case + sensitive. The server MUST include every metadata member that affects the + purchased operation or account selection, even when absent. It MUST NOT + include `x402/payment` or `progressToken`. An empty array is valid when no + metadata affects those decisions. + +The binding object MUST contain exactly these members: + +| Member | Value | +|---|---| +| `domain` | The string `"x402:exact:lnbtc:bolt11:mcp:1"`. | +| `server` | The independently validated `extra.requestBindingParams.server`. | +| `method` | The string `"tools/call"`. | +| `name` | The actual `params.name`, a non-empty string, without normalization. | +| `arguments` | The actual `params.arguments` object, before defaults or application transformations; `{}` if omitted. Null and non-object values MUST be rejected. | +| `metadata` | An array with one object per configured metadata name, in the same order. Each object has exactly `name` and `valueHash` members as defined below. | + +For each present bound metadata member, `valueHash` MUST be SHA-256 of +`0x01 || UTF8(JCS(value))`. For an absent member it MUST be SHA-256 of the single +byte `0x00`. Each digest MUST be encoded as 64 lowercase hexadecimal characters. +An absent `_meta` is treated as an empty object; a present non-object `_meta` +MUST be rejected. Null values remain distinct from absent values. + +The JSON-RPC `id`, payment metadata, progress token, and transport framing are +not binding inputs. A paid retry MAY change them without changing the purchased +operation. The tool MUST treat omitted arguments and `{}` identically. Servers +MUST reject operations that depend on any excluded input or unrepresented +context, including an account selected only by transport authentication or +session state. A bound account argument or metadata value MUST still be checked +against the authenticated caller's permissions. + +The server MUST associate `PaymentRequired.resource.url` with the actual tool +in its own configuration. Clients MUST derive the binding from the intended +server and tool call, never from the echoed resource URL. The server identity, +tool name, arguments, and relevant metadata together identify what is purchased. + +## `PaymentPayload` + +After paying the invoice, the client sends the preimage in the scheme-specific +`payload` object. The invoice remains in `accepted.extra.invoice`: + +```json +{ + "x402Version": 2, + "accepted": { + "scheme": "exact", + "network": "lnbtc:000000000019d6689c085ae165831e93", + "amount": "25000", + "asset": "BTC", + "payTo": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "maxTimeoutSeconds": 300, + "extra": { + "assetTransferMethod": "bolt11", + "paymentFlow": "upfront", + "requestHash": "0d6623f775e025501fa7f0a30b54da25aad62b6ccfe35c85da38016711e6c018", + "requestBindingProfile": "http:1", + "requestBindingParams": { "headers": [] }, + "invoice": "lnbc250n1pj48ugqpp54y3u9s8ylemsv8l3ewyzzu0klhujvuvmkl6llchq23vy8rzjsf0qsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp5p4nz8am4uqj4q8a87z3sk4x6yk4dv2mvel34epw68qqkwy0xcqvqxqzfvcqpjr4rx6ls6j5rpwknuea64evlk7yfx56wmqcer5eerekdsn9tlv6v4ex9mlz5dtm9qapl3svwlqcf7837dmjkru9z9w4h2rvm0md52w2sqxrwu5f" + } + }, + "payload": { + "preimage": "0001020304050607080900010203040506070809000102030405060708090102" + } +} +``` + +`payload.preimage` is required: + +| Field | Type | Requirements | +|---|---|---| +| `preimage` | string | MUST be exactly 64 lowercase hexadecimal characters encoding 32 bytes. | + +`accepted.extra.invoice` MUST be byte-identical to the invoice that the client +paid. It MAY differ from a newly generated `requirements.extra.invoice` on the +retry. Its signing key, request binding, and payment terms MUST pass the checks +below. No additional challenge identifier or request copy is required. + +## Request Binding Test Vectors + +### HTTP + +The examples above describe `GET https://api.example.com/article/A` with an empty +body and no bound headers. They use the test-only secp256k1 private key +`0000000000000000000000000000000000000000000000000000000000000001`. +The canonical description is the following single line, encoded as UTF-8 without +its trailing newline: + +```json +{"bodyHash":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","domain":"x402:exact:lnbtc:bolt11:http:1","headers":[],"method":"GET","url":"https://api.example.com/article/A"} +``` + +Its SHA-256 digest, which MUST equal `extra.requestHash` and the signed BOLT11 +description hash, is: + +```text +0d6623f775e025501fa7f0a30b54da25aad62b6ccfe35c85da38016711e6c018 +``` + +Changing only the URL to `https://api.example.com/article/B` produces this digest: + +```text +4a99860f75eed1ea8178a5db488e044173bc570c8a6210f2c8590cdf8622d509 +``` + +Each case below starts independently at validation time `1700000000` with an +empty replay store. Mutations leave the accepted invoice and preimage unchanged +unless stated otherwise. The server computes `requirements.extra.requestHash` +from the actual request before calling `/settle`. + +| Case | Expected result | +|---|---| +| Unchanged examples, including different JSON member order or whitespace. | Settlement succeeds. | +| Replace only `requirements.extra.invoice` with a fresh valid invoice for the same request and terms. | Settlement succeeds using the original accepted invoice. | +| Issue two concurrent challenges for the same request and terms. Pay only the first invoice, then present its proof against the second challenge. | Settlement succeeds using the first invoice; no record linking it to the second challenge is needed. | +| Present the same paid proof concurrently against both challenges for the same request and terms. | Exactly one settlement succeeds; the other returns `duplicate_settlement`. | +| Pay both invoices for concurrent challenges with the same request and terms, then present each invoice's own proof. | Both settlements succeed; each payment has a distinct consumption key. | +| Present article A's proof with an actual request for article B at the same price. | `invalid_exact_lnbtc_request_mismatch`. | +| Also change the accepted `requestHash` to article B's digest. | `invalid_exact_lnbtc_invoice_request_mismatch`; the invoice still commits to article A. | +| Change the actual method to `POST`, or body to the single byte `78` (hexadecimal), and echo the new digest. | `invalid_exact_lnbtc_invoice_request_mismatch`. | +| Remove `requestHash`, `requestBindingProfile`, or `requestBindingParams` from either side. | `invalid_exact_lnbtc_request_binding`. | +| Use an unknown profile, omit its required parameters, or add an unknown parameter. | `invalid_exact_lnbtc_request_binding`. | +| Change only the accepted header list. | `invalid_exact_lnbtc_request_mismatch`. | +| Replace the accepted invoice with a valid signed invoice containing an inline description instead of a description hash. | `invalid_exact_lnbtc_invoice_description`. | + +### MCP + +For `tools/call` with `params.name` equal to `"get_article"`, +`params.arguments` equal to `{ "article": "A" }`, and no bound metadata, use: + +```json +{ + "requestBindingProfile": "mcp:1", + "requestBindingParams": { + "server": "https://api.example.com/mcp", + "metadata": [] + }, + "requestHash": "03941bfedc6af8a09b2f459fe83470284a76a8c75801caa9e1487a9276a693f4" +} +``` + +The canonical description is this single line, without its trailing newline: + +```json +{"arguments":{"article":"A"},"domain":"x402:exact:lnbtc:bolt11:mcp:1","metadata":[],"method":"tools/call","name":"get_article","server":"https://api.example.com/mcp"} +``` + +Its SHA-256 digest is the `requestHash` above. The following independent changes +produce these digests: + +| Change | Request hash | +|---|---| +| Change `arguments.article` to `"B"`. | `b3e425970d64cd4f08fc4d57a11b76da59ce6a5760d92687398c91f063120678` | +| Change `name` to `"delete_article"`. | `3a52bbf19dda8b5765a27246b12e805770298273b48526956c421f02fe043455` | +| Change `server` to `"https://other.example.com/mcp"`. | `96903c29186c6aabc95e48abafd8ce3ad32b4060f5d5bf22cf75f3fbfe816e45` | + +For a bound metadata name, an absent value hashes to +`6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d`; +a present `null` hashes to +`c58dcb77cee9027d1f4b3207bd876d232e61f79ee9f9dbd4e6d834778da78b16`. + +For the settlement cases below, issue and pay an otherwise valid invoice whose +description hash is the MCP article A digest. Each case starts independently +with an empty replay store while the invoice is valid. Recompute requirements +from the actual call, leaving the accepted invoice unchanged unless stated. + +| Case | Expected result | +|---|---| +| Retry the same tool call with a new JSON-RPC `id`, a changed `progressToken`, and the payment in `_meta["x402/payment"]`. | The request hash is unchanged; settlement succeeds. | +| Change only JSON whitespace or member order in the tool arguments. | The request hash is unchanged; settlement succeeds. | +| Change the actual tool name or arguments while keeping the original accepted binding. | `invalid_exact_lnbtc_request_mismatch`. | +| Also echo the new request hash after changing the tool name or arguments. | `invalid_exact_lnbtc_invoice_request_mismatch`. | +| Change the configured server identity and echo the new parameters and request hash. | `invalid_exact_lnbtc_invoice_request_mismatch`. | +| Use the HTTP article A invoice and preimage with accepted binding fields copied from valid MCP requirements. | `invalid_exact_lnbtc_invoice_request_mismatch`; the HTTP and MCP domains differ. | +| Change only the accepted profile and its parameters to a valid `http:1` binding. | `invalid_exact_lnbtc_request_mismatch`. | +| Advertise `http:1` for an MCP tool call, even over HTTP. | The client rejects the challenge before payment. | +| Omit a profile or required parameter, use an unknown profile, or include `x402/payment` or `progressToken` in the metadata list. | `invalid_exact_lnbtc_request_binding`. | +| With a separately issued invoice binding a metadata value, change that value (including absent to null) and echo the new digest. | `invalid_exact_lnbtc_invoice_request_mismatch`. | + +## Client Payment Construction + +Before paying, a client MUST: + +1. Require `scheme == "exact"`, a supported concrete network, `asset == "BTC"`, a + positive integral `amount`, a positive integral `maxTimeoutSeconds`, and a valid + compressed secp256k1 `payTo` encoded as 66 lowercase hexadecimal characters. +2. Require `extra.paymentFlow == "upfront"` and a non-empty `extra.invoice`. Treat + a missing `extra.assetTransferMethod` as `"bolt11"` and reject any other value. +3. Strictly decode and verify the BOLT11 invoice and its signature. +4. Validate `extra.requestHash`, `extra.requestBindingProfile`, and + `extra.requestBindingParams`. Require the profile to match the actual + operation's transport and compute the digest from the intended request using + that profile's rules, including its resource and server identity checks. + Require exactly one description hash and no inline description. Both the + description hash and `extra.requestHash` MUST equal the computed digest. +5. Require the invoice signing key to equal `payTo`. +6. Require the invoice currency to match the selected network. +7. Require the invoice to specify an integral millisatoshi amount equal to + `PaymentRequirements.amount`. +8. Require the invoice expiry to equal `maxTimeoutSeconds` and its creation time to + be no later than the client's validation time plus its configured non-negative + clock-skew allowance, whose default MUST be 60 seconds. +9. Require the invoice to be unexpired at the client's validation time. +10. Ask its payer adapter to pay the invoice on the selected network. + +The payer result MUST report `paid` and identify the same invoice, payment hash, +and invoice amount. Any separately reported routing fee MUST NOT be included in +the amount comparison. The client SHOULD report `in_flight` as a distinct result +so the caller can retry without starting a second payment. For a paid result, +the client MUST validate the preimage format and SHA-256 digest. It MUST NOT +construct a `PaymentPayload` if a check fails. + +## Facilitator Validation + +The `upfront` flow does not invoke facilitator `/verify`. The facilitator's +`/settle` endpoint MUST treat the payload and echoed requirements as untrusted and +perform the following checks in order before it records the payment hash: + +1. Require `accepted.scheme`, `network`, `amount`, `asset`, `payTo`, and + `maxTimeoutSeconds` to equal the corresponding requirement fields. +2. Require `scheme == "exact"`, a supported network, `asset == "BTC"`, positive + integral `amount` and `maxTimeoutSeconds` values, and a valid compressed + secp256k1 `payTo` encoded as 66 lowercase hexadecimal characters. +3. Resolve a missing `extra.assetTransferMethod` to `bolt11` on both sides and + require `bolt11`. Require `extra.paymentFlow == "upfront"` on both sides. + Require both request hashes to be 64 lowercase hexadecimal characters. + Require supported profiles and validate the syntax of both parameter objects + using the Request Binding rules. Require the hashes, profiles, and parameters + to match; parameter equality uses JCS serialization. Every other + server-declared `extra` field except `invoice` MUST have the same value in + `accepted`; additive client fields MAY remain. +4. Require non-empty invoices in `requirements.extra.invoice` and + `accepted.extra.invoice`. The facilitator MUST use the accepted invoice for + settlement and MUST NOT require the two invoices to be equal. +5. Strictly decode and verify the accepted invoice and its signature. Require + exactly one description hash and no inline description. + Require the signed description hash to equal the 32-byte digest decoded from + `requirements.extra.requestHash`. This check is mandatory; missing binding + fields MUST fail. The facilitator checks the server-supplied expected digest; + it does not need the original request. + Require its signing key to equal `requirements.payTo`, its BOLT11 currency to + match the network, and its integral millisatoshi amount to equal + `requirements.amount`. + Require its expiry to equal `requirements.maxTimeoutSeconds`, and require its + creation time not to exceed the facilitator's settlement time plus the + configured clock-skew allowance. +6. Require the preimage to contain exactly 64 lowercase hexadecimal characters. + Decode it as exactly 32 bytes and require + `SHA-256(preimage_bytes) == payment_hash_bytes`. +7. Apply the expiry policy below. + +The facilitator MUST verify the preimage locally and MUST NOT require receiver +access. + +### Paid-but-expired Policy + +Let: + +- `invoice_end = invoice_creation_time + invoice_expiry_seconds` +- `settlement_time =` the facilitator's validation time during `/settle` +- `skew =` the facilitator's configured non-negative clock-skew allowance, whose + default MUST be 60 seconds + +The invoice passes the expiry check while +`settlement_time <= invoice_end + skew`, including after the BOLT11 expiry time. It +MUST fail the check after this boundary. Equality at the boundary is valid. + +This grace period permits a retry when payment completed shortly before expiry. + +## Settlement and Replay Protection + +Settlement does not move funds. The Lightning payment completed before the client +received the preimage. Cryptographic proof verification is stateless, but enforcing +single-use settlement requires a replay store. The canonical consumption key MUST +be the ASCII string: + +```text +network + ":" + payment_hash +``` + +`network` is the validated concrete CAIP-2 network identifier. `payment_hash` is +the accepted invoice's payment hash, encoded as 64 lowercase hexadecimal +characters without a prefix. The separator is one colon, with no whitespace. + +After validation, the facilitator MUST atomically insert this key into a +restart-durable replay store. The insert MUST fail if the key already exists. In +that case, the facilitator MUST return `duplicate_settlement`. The resource server +MUST NOT process the protected request until the insert succeeds. The same hash +on different networks produces different keys. + +The replay entry MUST remain until at least one hour after +`invoice_end + skew`. It MUST NOT be removed while the invoice can still pass +validation. + +On success, `SettlementResponse.transaction` MUST be the lowercase invoice payment +hash and `network` MUST be the concrete `lnbtc` network. `payer` MUST be omitted +because Lightning does not reveal a stable payer address. + +```json +{ + "success": true, + "transaction": "a923c2c0e4fe77061ff1cb882171f6fdf926719bb7f5ffe2e05458438c52825e", + "network": "lnbtc:000000000019d6689c085ae165831e93" +} +``` + +## Error Vocabulary + +Facilitators MUST use the following stable strings in `errorReason`. Settlement +MUST preserve the validation reason when validation fails. + +| Reason | Meaning | +|---|---| +| `unsupported_scheme` | Either side is not `exact`. | +| `network_mismatch` | `accepted.network` differs from the requirement. | +| `unsupported_network` | The concrete `lnbtc` network is unsupported. | +| `invalid_exact_lnbtc_asset` | Either side does not specify `BTC`. | +| `invalid_exact_lnbtc_amount` | Either amount is not a positive integer. | +| `invalid_exact_lnbtc_amount_mismatch` | `accepted.amount` differs from the requirement. | +| `invalid_exact_lnbtc_pay_to_mismatch` | `accepted.payTo` differs from the requirement. | +| `invalid_exact_lnbtc_pay_to_malformed` | `payTo` is not a lowercase compressed secp256k1 public key. | +| `invalid_exact_lnbtc_max_timeout_mismatch` | `accepted.maxTimeoutSeconds` differs from the requirement. | +| `invalid_exact_lnbtc_extra_mismatch` | A server-declared `extra` field other than `invoice`, `requestHash`, `requestBindingProfile`, or `requestBindingParams` differs. | +| `invalid_exact_lnbtc_request_binding` | A request hash, profile, or parameter object is missing or malformed, or a profile is unsupported. | +| `invalid_exact_lnbtc_request_mismatch` | The accepted request hash, profile, or parameters differ from the requirement. | +| `invalid_exact_lnbtc_asset_transfer_method` | Either explicit asset transfer method is not `bolt11`. | +| `invalid_exact_lnbtc_payment_flow` | Either payment flow is missing or not `upfront`. | +| `invalid_exact_lnbtc_invoice_missing` | Either required invoice field is absent. | +| `invalid_exact_lnbtc_invoice_decode_failed` | Strict BOLT11 decoding, signature validation, or integral-msat validation failed. | +| `invalid_exact_lnbtc_invoice_description` | The invoice does not contain exactly one description hash or contains an inline description. | +| `invalid_exact_lnbtc_invoice_request_mismatch` | The signed description hash differs from the expected request hash. | +| `invalid_exact_lnbtc_invoice_payee_mismatch` | The invoice signing key differs from `payTo`. | +| `invalid_exact_lnbtc_invoice_currency_mismatch` | BOLT11 currency does not match the network. | +| `invalid_exact_lnbtc_invoice_amount_mismatch` | BOLT11 amount differs from the required millisatoshis. | +| `invalid_exact_lnbtc_max_timeout` | `maxTimeoutSeconds` is not a positive integer. | +| `invalid_exact_lnbtc_invoice_expiry_mismatch` | BOLT11 expiry does not equal `maxTimeoutSeconds`. | +| `invalid_exact_lnbtc_invoice_created_in_future` | BOLT11 creation time exceeds validation time plus the clock-skew allowance. | +| `duplicate_settlement` | The network and payment-hash pair is already used or lost an atomic settlement race. | +| `invalid_exact_lnbtc_preimage_missing` | `payload.preimage` is absent. | +| `invalid_exact_lnbtc_preimage_malformed` | Preimage contains non-lowercase-hex characters. | +| `invalid_exact_lnbtc_preimage_length` | Decoded preimage is not exactly 32 bytes. | +| `invalid_exact_lnbtc_preimage_hash_mismatch` | SHA-256 of the preimage does not equal the payment hash. | +| `invalid_exact_lnbtc_invoice_expired` | The paid-but-expired settlement-time window was exceeded. | + +Client and server implementations SHOULD use these stable local failure reasons. +They are not facilitator response reasons unless a transport explicitly maps a local +failure into one: + +| Reason | Meaning | +|---|---| +| `exact_lnbtc_invoice_issuance_denied` | The server's issuance limiter denied a new invoice. | +| `invalid_exact_lnbtc_payer_invoice_mismatch` | The payer adapter returned a different invoice. | +| `invalid_exact_lnbtc_payer_payment_hash_mismatch` | The payer adapter returned a different payment hash. | +| `invalid_exact_lnbtc_payer_amount_mismatch` | The payer adapter returned a different amount. | +| `exact_lnbtc_payment_in_flight` | Payment is still in flight and may be retried. | +| `exact_lnbtc_payment_not_paid` | The payer did not report a paid status. | +| `invalid_exact_lnbtc_payer_preimage_required` | A paid result omitted the mandatory preimage. | +| `invalid_exact_lnbtc_payer_preimage_malformed` | A payer preimage is not 64 lowercase hex characters. | +| `invalid_exact_lnbtc_payer_preimage_hash_mismatch` | A payer preimage does not hash to the invoice payment hash. | + +## Security Considerations + +### Mandatory Cryptographic Proof + +The preimage is bearer proof of payment. Implementations MUST avoid logging or +otherwise disclosing it. A client MUST send it only to the resource server for the +invoice it paid. The facilitator can verify the invoice and preimage without +receiver credentials. + +### Invoice Substitution + +On a paid retry, the accepted invoice can differ from the new challenge invoice. +The server MUST compute the expected request hash from the incoming request +before matching requirements. A matching client echo alone is insufficient. The +client and facilitator MUST check the signed description hash against the +expected digest and require the signing key to equal `payTo` and the +payment terms to match. A proof for another request fails even if its price and +receiver are the same. A self-issued invoice fails unless the attacker controls +the receiver node key. + +Request binding does not identify the payer. A disclosed preimage and invoice +remain bearer proof for the bound request; replay protection permits at most one +successful claim. No server challenge store or receiver lookup is required. + +### Receiver Key Isolation + +`payTo` binds the invoice to the receiver node. A shared custodial node is not +compatible if an untrusted tenant can create invoices under the same node key. The +tenant could pay its own invoice and use the preimage against another tenant's +payment requirement. A compliant deployment MUST give the resource server +exclusive invoice-issuance authority for the receiver key. + +### Invoice Issuance Denial of Service + +Fresh invoices consume receiver node resources. Servers SHOULD limit or authorize +invoice issuance before calling the receiver. They MUST NOT reuse an invoice +across clients or challenges. If the transport provides a payment payload before +challenge generation, the server SHOULD validate it before it creates a replacement +invoice. + +### Network and Currency Confusion + +Servers, clients, and facilitators MUST map the concrete `lnbtc` network to the +expected BOLT11 currency and reject mismatches. In particular, a `tb` testnet +invoice MUST NOT appear under the mainnet identifier. + +### Durable Replay Protection + +An in-memory replay store is not compliant because a restart loses consumed keys. +All facilitator instances that settle for the same receiver MUST share a +restart-durable replay store with an atomic insert. A resource server MUST NOT send +invoices for one receiver to independent replay stores. A database can enforce +this rule with a unique canonical consumption key. Persistent state is required +because Lightning has no public spent marker for the bearer proof. + +### Payer Anonymity + +Lightning routing does not give a facilitator a stable payer identity. Facilitators +MUST omit `SettlementResponse.payer` and MUST NOT infer payer identity from `payTo` +or the invoice payee. + +## References + +- [x402 protocol specification v2](../../x402-specification-v2.md) +- [BOLT11 payment encoding](https://github.com/lightning/bolts/blob/master/11-payment-encoding.md) +- [JSON Canonicalization Scheme (RFC 8785)](https://www.rfc-editor.org/rfc/rfc8785.html) +- [HTTP message components (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421.html#section-2) +- [x402 MCP transport](../../transports-v2/mcp.md) +- [MCP tool calls](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools) +- [CAIP-2 chain identification](https://chainagnostic.org/CAIPs/caip-2) diff --git a/specs/schemes/exact/scheme_exact_starknet.md b/specs/schemes/exact/scheme_exact_starknet.md index 28868bacea..4d778150e4 100644 --- a/specs/schemes/exact/scheme_exact_starknet.md +++ b/specs/schemes/exact/scheme_exact_starknet.md @@ -2,10 +2,10 @@ ## Summary -The `exact` scheme on Starknet transfers a specific amount of a token (e.g., USDC) from the client to the resource server using [SNIP-9 Outside Execution](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md). The client signs a [SNIP-12](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md) typed-data message authorizing exactly one `transfer` call from its own account contract. The facilitator causes that authorization to execute on-chain via `execute_from_outside_v2` on the client's account — from its own account or a forwarder it routes through — and pays all gas. +The `exact` scheme on Starknet transfers a specific amount of a token (e.g., USDC) from the client to the resource server using [SNIP-9 Outside Execution](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-9.md). The client signs a [SNIP-12](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md) typed-data message authorizing exactly one `transfer` call from its own account contract. The facilitator causes that authorization to execute onchain via `execute_from_outside_v2` on the client's account — from its own account or a forwarder it routes through — and pays all gas. - **No token approvals and no client gas**: the signed message contains the exact transfer calldata; the client never submits a transaction. There is no `approve`/`transfer_from` step and no spender allowance. -- **On-chain replay protection**: SNIP-9 nonces are single-use, independent of the account's transaction nonce, and enforced by the client's account contract; the facilitator needs no persistent state. +- **Onchain replay protection**: SNIP-9 nonces are single-use, independent of the account's transaction nonce, and enforced by the client's account contract; the facilitator needs no persistent state. - **Account-abstraction native**: signature validity is checked via the account's own [SNIP-6](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-6.md) `is_valid_signature`, so any account implementation (standard, multisig, hardware-backed) works. ## Versions supported @@ -28,8 +28,8 @@ The CAIP-2 reference is the string form of the chain id returned by `starknet_ch - **Client**: The end user initiating the payment; owner of the payer account contract (`payload.from`). - **Merchant**: The resource server receiving payment, identified by `payTo`. -- **Sponsor**: The entity that pays the gas for settlement and causes the `OutsideExecution` to execute on-chain — the **facilitator**, which MAY be operated by the merchant itself or by a third party. The client never communicates with it directly; all facilitator interaction is proxied through the resource server. -- **Fee Payer (`feePayer`)**: The Starknet address the client MUST set as the SNIP-9 `Caller`, and which will therefore be the on-chain caller of `execute_from_outside_v2` — the sponsor's own executor account, or a forwarder it routes through. The name is aligned with the sibling network specs; note that on Starknet this address is the *submission authority* the payer's account checks. It is the account that pays gas only in the direct-executor case — when the sponsor routes through a forwarder, the `Caller` is a contract and the fee is paid by the sponsor's relayer behind it. Which of the two the sponsor uses is a sponsor-local implementation detail that never reaches the client. +- **Sponsor**: The entity that pays the gas for settlement and causes the `OutsideExecution` to execute onchain — the **facilitator**, which MAY be operated by the merchant itself or by a third party. The client never communicates with it directly; all facilitator interaction is proxied through the resource server. +- **Fee Payer (`feePayer`)**: The Starknet address the client MUST set as the SNIP-9 `Caller`, and which will therefore be the onchain caller of `execute_from_outside_v2` — the sponsor's own executor account, or a forwarder it routes through. The name is aligned with the sibling network specs; note that on Starknet this address is the *submission authority* the payer's account checks. It is the account that pays gas only in the direct-executor case — when the sponsor routes through a forwarder, the `Caller` is a contract and the fee is paid by the sponsor's relayer behind it. Which of the two the sponsor uses is a sponsor-local implementation detail that never reaches the client. ## Protocol Flow @@ -72,6 +72,14 @@ The protocol flow for `exact` on Starknet is client-driven: the client builds an - `extra.feePayer` MUST be a concrete address: it MUST NOT be zero, MUST NOT equal `payload.from`, and MUST NOT be the SNIP-9 any-caller sentinel `0x414e595f43414c4c4552` (the short string `ANY_CALLER`), which the payer's account treats as a wildcard and which would silently turn a bound authorization back into a bearer one that anyone could submit. It MAY equal `payTo` (merchant-sponsored settlement). Clients MUST refuse to sign against a forbidden value; facilitators MUST reject it (rules 1 and 4). - Because `extra.feePayer` is required, every payment in this scheme is sponsored and the client never needs gas funds. How the sponsor settles behind that address — its own account, a forwarder, or a SNIP-29 paymaster — is facilitator-local configuration, MUST NOT be required from the client-facing `PaymentRequirements`, and MUST NOT require any client↔facilitator interaction: the client builds and signs the `OutsideExecution` from the `PaymentRequirements` alone. Whatever assembles the typed data on the client side, the signer MUST verify the final message against its own intent before signing — exactly one `Call` with `To` = the requirements' `asset`, `Selector` = `sn_keccak("transfer")`, `Calldata` = `[payTo, amount_low, amount_high]` for the exact required amount, `Caller` = `extra.feePayer`, and time bounds per the Timeout Mapping — since the signature authorizes exactly what the message says. +### Payment Flow and Asset Transfer Method + +The core specification reserves two `extra` keys, `assetTransferMethod` and `paymentFlow` (see [Payment Flow Models](../../x402-specification-v2.md), section 6.1). This scheme fills them in as follows. + +Payment flow is always `authorization`: the facilitator verifies the payment before the resource server returns the resource, and settles it afterward. `extra.paymentFlow` MAY be omitted, or set explicitly to `"authorization"`. + +The scheme has a single asset transfer method, `"default"`, in the [facilitator-submitted](./scheme_exact.md#facilitator-submitted) family: the client signs a SNIP-9 `OutsideExecution` and the sponsor executes it, so the client has nothing to choose. `extra.assetTransferMethod` MAY be omitted, or set explicitly to `"default"`. + ### Timeout Mapping: `maxTimeoutSeconds` → Time Bounds SNIP-9 time bounds are Unix seconds checked strictly (`Execute After < block_timestamp < Execute Before`) against the sequencer's block timestamp, which can lag wall clock. Facilitators MUST absorb that lag with a skew margin `skewMargin` (SHOULD be ≤ 60 seconds, an upper bound on observed sequencer timestamp lag) wherever it could flip a check: @@ -82,7 +90,7 @@ SNIP-9 time bounds are Unix seconds checked strictly (`Execute After < block_tim - MUST reject if `Execute Before < now + maxTimeoutSeconds - skewMargin` (`outside_execution_expired`) — the authorization MUST remain valid for the full advertised settlement window, so a stale signature (or one signed against a smaller window) is rejected up front rather than left to expire mid-settlement. - MUST reject if `Execute Before > now + maxTimeoutSeconds + skewMargin` (`outside_execution_window_exceeds_max_timeout`). - With the client signing rule, these bound `Execute Before` to `now + maxTimeoutSeconds ± skewMargin`: a payload is only accepted within `skewMargin` of signing. -- At **both** `/verify` and settlement-time re-verification: MUST reject if `Execute Before <= now + minSettleMargin` (RECOMMENDED ≥ 30 seconds, covering broadcast-to-inclusion latency) so a settlement transaction cannot expire in flight (`outside_execution_expired`). This floor is the binding lower bound when `maxTimeoutSeconds` is small; servers SHOULD advertise `maxTimeoutSeconds` comfortably above `skewMargin + minSettleMargin`. +- At **both** `/verify` and settlement-time re-verification: MUST reject if `Execute Before <= now + minSettleMargin` (RECOMMENDED ≥ 30 seconds, covering broadcast-to-inclusion latency) so a settlement transaction cannot expire in flight (`outside_execution_expired`). This floor is the binding lower bound when `maxTimeoutSeconds` is small; servers SHOULD advertise `maxTimeoutSeconds` comfortably above `skewMargin + minSettleMargin`. Facilitators MUST reject requirements whose `maxTimeoutSeconds` is not a positive integer, or is at or below `minSettleMargin`, with `invalid_payment_requirements`: no conforming client can satisfy such a window — it signs `Execute Before = now + maxTimeoutSeconds`, which the settle floor then rejects at once. - At settlement-time re-verification the freshness lower bound (`Execute Before >= now + maxTimeoutSeconds - skewMargin`) does not re-apply — remaining validity necessarily shrinks while the resource server prepares settlement; only the minimum-remaining-window floor and the upper bound are enforced. ## `PaymentPayload` `payload` Field @@ -160,6 +168,8 @@ The typed data MUST be a SNIP-12 revision `1` `OutsideExecution` (SNIP-9 version > **Hash-encoding note (SNIP-9/SNIP-12 exception):** when computing the SNIP-12 domain hash, `version` MUST encode as the felt integer `2` (not the short string `'2'` = `0x32`) and `revision` MUST encode as the felt integer `1` (not `'1'` = `0x31`), despite both fields being typed `shortstring`. `chainId` encodes as the short-string felt. Standard SNIP-12 implementations handle this exception. +> **Selector-encoding note:** `Selector` is the felt `sn_keccak("transfer")` and MUST enter the SNIP-12 hash as that felt, not be hashed again as a name. + ### Full `PaymentPayload` Example ```json @@ -193,7 +203,7 @@ The typed data MUST be a SNIP-12 revision `1` `OutsideExecution` (SNIP-9 version ## Facilitator Verification Rules (MUST) -A facilitator MUST enforce all of the following before returning `isValid: true`. If any check cannot be safely determined (RPC failure, unparsable value), verification MUST fail closed. +A facilitator MUST enforce all of the following before returning `isValid: true`. If any check cannot be safely determined (RPC failure, unparsable value), verification MUST fail closed. A facilitator MUST NOT report a payment-state code (`account_not_deployed`, `invalid_exact_starknet_payload_signature`, `nonce_already_used`, `insufficient_funds`) for onchain state it never observed: those tokens are terminal assertions consumers act on, and emitting them during an RPC brownout makes a client discard an intact authorization for a payment that was never broadcast. Transport-level RPC failures — timeouts, connection errors, node faults — on the class-hash, `is_valid_signature`, `is_valid_outside_execution_nonce`, and balance reads MUST therefore fail closed with `unexpected_verify_error` (retryable). The same applies to a simulation the node could not complete or whose trace is unreadable: it too is an absent answer, not a verdict, so it MUST fail closed with `unexpected_verify_error`. `simulation_failed` is reserved for a simulation the node actually ran and that reverted or showed the wrong transfers. All felt/address comparisons MUST be performed numerically (as field elements), never as strings: Starknet addresses and felts have no canonical hex padding or case. @@ -202,23 +212,24 @@ All felt/address comparisons MUST be performed numerically (as field elements), - `PaymentPayload.x402Version` MUST be `2`. - `accepted.scheme` MUST be `exact` and `accepted.network` MUST be a supported `starknet:*` identifier. - `accepted` MUST match the `PaymentRequirements` supplied by the resource server to the facilitator — never the client's echo — field-by-field (`scheme`, `network`, `amount`, `asset`, `payTo`, `maxTimeoutSeconds`, and `extra.feePayer`). A mismatch MUST be rejected with `invalid_payload` (`invalid_scheme`/`invalid_network` for those two fields). -- The server-supplied requirements themselves MUST be well-formed: `amount` a base-10 integer string, `maxTimeoutSeconds` a finite positive number, and `extra.feePayer` present and a valid non-zero Starknet address that is neither the any-caller sentinel nor equal to `payload.from`. The facilitator MUST additionally reject a `feePayer` it cannot itself settle through — a stale or foreign value would produce an authorization only some other party could execute. Malformed or incomplete requirements MUST be rejected (`invalid_payment_requirements`) — implementations MUST NOT let a missing field silently disable a dependent check (e.g., a NaN window bound, or an absent `feePayer` turning the caller binding into a no-op). +- Both reserved keys are checked by value when they are present. If `extra.assetTransferMethod` appears in the server-supplied requirements it MUST be `"default"`, and if `extra.paymentFlow` appears it MUST be `"authorization"` (see Payment Flow and Asset Transfer Method above); otherwise the facilitator rejects the requirements with `invalid_payment_requirements`. The same values are required when either key appears in `accepted.extra`, else `invalid_payload`. Either key MAY be absent, and the two objects need not carry the same keys. +- The server-supplied requirements themselves MUST be well-formed: `amount` a base-10 integer string, `maxTimeoutSeconds` a positive integer above `minSettleMargin` (Timeout Mapping), and `extra.feePayer` present and a valid non-zero Starknet address that is neither the any-caller sentinel nor equal to `payload.from`. The facilitator MUST additionally reject a `feePayer` it cannot itself settle through — a stale or foreign value would produce an authorization only some other party could execute. Malformed or incomplete requirements MUST be rejected (`invalid_payment_requirements`) — implementations MUST NOT let a missing field silently disable a dependent check (e.g., a NaN window bound, or an absent `feePayer` turning the caller binding into a no-op). ### 2. Typed Data Canonicalization - The facilitator MUST NOT compute the signature hash from the client-supplied `typedData` as received. It MUST parse `domain.chainId` and the five message fields (`Caller`, `Nonce`, `Execute After`, `Execute Before`, `Calls`), reconstruct the canonical SNIP-9 v2 typed data defined above from those values, and compute the SNIP-12 message hash from its own reconstruction. Unknown or missing keys in `domain` or `message` MUST be rejected (`invalid_payload`). -- `domain.chainId` MUST equal the chain id of `accepted.network` (`SN_MAIN` ↔ `starknet:SN_MAIN`, `SN_SEPOLIA` ↔ `starknet:SN_SEPOLIA`), comparing the short-string felt numerically. +- `domain.chainId` MUST equal the chain id of `accepted.network` (`SN_MAIN` ↔ `starknet:SN_MAIN`, `SN_SEPOLIA` ↔ `starknet:SN_SEPOLIA`). It MAY arrive as the hex felt (`0x534e5f4d41494e`), its decimal form, or the raw short string (`SN_MAIN`); all three encode the same felt and MUST be compared numerically after short-string encoding. The canonical reconstruction and the hash always use the felt. ### 3. Signature Verification - Compute the SNIP-12 message hash of the reconstructed typed data for the account `payload.from`. - Call `is_valid_signature(hash, signature)` on `payload.from` and require the SNIP-6 magic value `VALID` (`0x56414c4944`). Failure: `invalid_exact_starknet_payload_signature`. - The signature array MUST be passed to the account verbatim; facilitators MUST NOT assume a two-element `[r, s]` shape. -- The account contract MUST be deployed: if its class hash cannot be resolved, fail closed with `account_not_deployed`; if the `is_valid_signature` call reverts, fail closed with `invalid_exact_starknet_payload_signature`. +- The account contract MUST be deployed: if its class hash cannot be resolved because the node reports a structured contract-not-found error, fail closed with `account_not_deployed`; if the `is_valid_signature` call reverts or its entry point is missing (a structured contract-level error), fail closed with `invalid_exact_starknet_payload_signature`. ### 4. Caller Binding -- `message.Caller` MUST equal `accepted.extra.feePayer`. Any other value MUST be rejected (`invalid_exact_starknet_payload_caller`) — settlement would revert on-chain. +- `message.Caller` MUST equal `accepted.extra.feePayer`. Any other value MUST be rejected (`invalid_exact_starknet_payload_caller`) — settlement would revert onchain. - There is no sentinel branch and no trusted-forwarder exception. Rule 1 has already rejected any requirement whose `feePayer` is the any-caller sentinel, so a payload reaching this rule is always bound to a concrete address; a facilitator MUST NOT accept a `Caller` merely because it appears in local configuration. ### 5. Execution Time Window @@ -227,7 +238,7 @@ All felt/address comparisons MUST be performed numerically (as field elements), ### 6. Replay Protection -- Call `is_valid_outside_execution_nonce(message.Nonce)` on `payload.from`; the nonce MUST be unused, and a failed call (e.g. an account without SNIP-9 v2 support) fails closed — both with `nonce_already_used`. SNIP-9 nonces are single-use and enforced by the account contract at execution time, so no facilitator-side nonce storage is required. +- Call `is_valid_outside_execution_nonce(message.Nonce)` on `payload.from`; the nonce MUST be unused, and a failed call due to a structured contract-level error (e.g. an account without SNIP-9 v2 support: entry point not found) fails closed — both with `nonce_already_used`. SNIP-9 nonces are single-use and enforced by the account contract at execution time, so no facilitator-side nonce storage is required. ### 7. Payment Intent and Exactness @@ -241,7 +252,7 @@ All felt/address comparisons MUST be performed numerically (as field elements), ### 8. Chain-State Preflight - The payer's `accepted.asset` balance MUST be a valid u256 (via `balance_of` or `balanceOf`) greater than or equal to `accepted.amount`, else `insufficient_funds`; a token exposing neither getter fails closed (`invalid_payment_requirements`). -- The facilitator MUST simulate the settlement via `starknet_simulateTransactions` (skip-validate, skip-fee-charge, v3 `INVOKE`) and MUST fail closed (`simulation_failed`) unless the trace shows exactly one `Transfer` emitted by `accepted.asset`, from `payer` to `payTo`, for exactly `accepted.amount`. An event that cannot be attributed to the `asset` contract (via its enclosing call frame) MUST NOT count as proof of payment. For both this rule and settlement step 4, a `Transfer` is an event emitted by `accepted.asset` whose first key is `sn_keccak("Transfer")`; facilitators MUST recognize both standard layouts — keyed (`keys = [selector, from, to]`, `data = [amount_low, amount_high]`) and legacy unkeyed (`keys = [selector]`, `data = [from, to, amount_low, amount_high]`) — and MUST fail closed on a matching-key event that fits neither layout. Simulating the full `execute_from_outside_v2` call tree is preferred, and facilitators SHOULD therefore advertise a `feePayer` they can originate a simulation from; when `extra.feePayer` is a forwarder the facilitator cannot originate from, the facilitator MAY instead simulate the `transfer` from the payer and rely on rules 3–6 plus the paymaster's own estimation at settlement. +- The facilitator MUST simulate the settlement via `starknet_simulateTransactions` (skip-validate, skip-fee-charge, v3 `INVOKE`) and MUST fail closed (`simulation_failed`) unless the trace shows exactly one `Transfer` emitted by `accepted.asset` whose sender (`from`) is `payer`, and that `Transfer` is to `payTo` for exactly `accepted.amount`. The exactly-one bound is scoped to the payer: `Transfer`s emitted by the same asset from other senders MUST NOT count against it. When `accepted.asset` is also the v3 fee token (STRK), a real settlement receipt necessarily carries the executor's fee `Transfer` emitted by that same contract, so an all-emitter exactly-one test would deny an honest settlement; the single-call rule (rule 7) still means an authorized payer moves exactly once, so a second payer-sent `Transfer` fails. An event that cannot be attributed to the `asset` contract (via its enclosing call frame) MUST NOT count as proof of payment. For both this rule and settlement step 4, a `Transfer` is an event emitted by `accepted.asset` whose first key is `sn_keccak("Transfer")`; facilitators MUST recognize both standard layouts — keyed (`keys = [selector, from, to]`, `data = [amount_low, amount_high]`) and legacy unkeyed (`keys = [selector]`, `data = [from, to, amount_low, amount_high]`) — and MUST fail closed on any asset-emitted, matching-key event that fits neither layout. Simulating the full `execute_from_outside_v2` call tree is preferred, and facilitators SHOULD therefore advertise a `feePayer` they can originate a simulation from; when `extra.feePayer` is a forwarder the facilitator cannot originate from, the facilitator MAY instead simulate the `transfer` from the payer and rely on rules 3–6 plus the paymaster's own estimation at settlement. ### 9. Facilitator Safety @@ -250,11 +261,11 @@ All felt/address comparisons MUST be performed numerically (as field elements), - The executor MUST only ever sign the `INVOKE` wrapping `execute_from_outside_v2`. - `payer` MUST only be included on a response once the payer's signature has been verified; it MUST NOT be echoed from unverified client input. -Unlike chains where the client sets the transaction fee, here the client signs only the inner OutsideExecution and the facilitator/paymaster alone sets the outer `INVOKE` fee — so a client cannot directly inflate the sponsored gas. The residual risk is gas spent on settlements that fail the effect check or revert despite passing verification (e.g. an account whose `is_valid_signature` and execution paths diverge, or an account upgraded between verify and settle). To bound it, facilitators SHOULD cap the sponsored settlement fee (reject when an estimate exceeds a configured bound) and apply per-payer rate limits, and — because settlement spends sponsored gas — SHOULD restrict which resource servers may request settlement. The mandatory simulation (rule 8) confirms on either simulation path that the only `asset` `Transfer` is payer → `payTo`; only the full call tree additionally exercises the account's execution path — the transfer-only fallback leaves exactly the residual gas risk described above. No balance change accrues to the facilitator's executor beyond the transaction fee. +Unlike chains where the client sets the transaction fee, here the client signs only the inner OutsideExecution and the facilitator/paymaster alone sets the outer `INVOKE` fee — so a client cannot directly inflate the sponsored gas. The residual risk is gas spent on settlements that fail the effect check or revert despite passing verification (e.g. an account whose `is_valid_signature` and execution paths diverge, or an account upgraded between verify and settle). To bound it, facilitators SHOULD cap the sponsored settlement fee (reject when an estimate exceeds a configured bound) and apply per-payer rate limits, and — because settlement spends sponsored gas — SHOULD restrict which resource servers may request settlement. The mandatory simulation (rule 8) confirms on either simulation path that the payer's only `asset` `Transfer` is to `payTo`; only the full call tree additionally exercises the account's execution path — the transfer-only fallback leaves exactly the residual gas risk described above. No balance change accrues to the facilitator's executor beyond the transaction fee. ### Error Codes -`invalidReason` and `errorReason` MUST be stable enum tokens (below) so consumers can switch on them; human-readable context MUST NOT be appended to the code, and MUST be carried in a separate diagnostic field if the response schema provides one. Standard v2 codes (`insufficient_funds`, `invalid_payload`, `invalid_payment_requirements`, `invalid_scheme`, `invalid_network`, `invalid_x402_version`, `invalid_transaction_state`, `unexpected_verify_error`, `unexpected_settle_error`) apply as usual. Starknet-specific values: +`invalidReason` and `errorReason` MUST be stable enum tokens (below) so consumers can switch on them; human-readable context MUST NOT be appended to the code, and MUST be carried in a separate diagnostic field if the response schema provides one. Standard v2 codes (`insufficient_funds`, `invalid_payload`, `invalid_payment_requirements`, `invalid_scheme`, `invalid_network`, `invalid_x402_version`, `invalid_transaction_state`, `unexpected_verify_error`, `unexpected_settle_error`, `settlement_pending`) apply as usual. Starknet-specific values, plus the standard `settlement_pending` as this scheme applies it: | Code | Meaning | | ---- | ------- | @@ -267,9 +278,9 @@ Unlike chains where the client sets the transaction fee, here the client signs o | `outside_execution_expired` | `Execute Before` has passed or leaves less than the required window (freshness band at `/verify`; `minSettleMargin` floor at both phases) | | `outside_execution_window_exceeds_max_timeout` | window exceeds `maxTimeoutSeconds` budget | | `nonce_already_used` | SNIP-9 nonce already consumed | -| `simulation_failed` | settlement simulation reverted or showed unexpected transfers | +| `simulation_failed` | settlement simulation ran and reverted or showed unexpected transfers (a simulation the node could not complete is `unexpected_verify_error`) | | `duplicate_settlement` | same nonce already submitted by this facilitator | -| `settlement_pending` | broadcast succeeded but confirmation is not yet established — **non-terminal**; deliberately carries a non-empty `transaction` with `success: false` so the caller can reconcile on-chain before retrying | +| `settlement_pending` | broadcast succeeded but confirmation is not yet established — **non-terminal**, `success: false` (see [x402 v2 §9 Error Handling](../../x402-specification-v2.md#9-error-handling)); MUST carry the non-empty broadcast `transaction` so the caller can reconcile it onchain before retrying | The `amount` in `PaymentRequirements` MUST be a base-10 integer string; hex/octal/binary/signed/whitespace forms MUST be rejected (`invalid_payment_requirements`). Facilitators SHOULD bound the `PaymentPayload` signature array length (e.g. reject beyond ~32 felts) so an oversized signature cannot amplify resource use. @@ -279,28 +290,30 @@ All checks are implementable against a stock public Starknet JSON-RPC node: | Check | Method | | ----- | ------ | -| Account deployment (§3) | `starknet_getClassHashAt` (class-resolution failure → `account_not_deployed`) | +| Account deployment (§3) | `starknet_getClassHashAt` (structured contract-not-found → `account_not_deployed`) | | Signature (§3) | `starknet_call` → `is_valid_signature` on the account | | Nonce (§6) | `starknet_call` → `is_valid_outside_execution_nonce` on the account | | Balance (§8) | `starknet_call` → `balance_of`/`balanceOf` on the token | | Simulation (§8) | `starknet_simulateTransactions` | | Current time (§5) | wall clock, optionally cross-checked with the latest block timestamp via `starknet_getBlockWithTxHashes` | +All verification state reads and simulations MUST use an accepted-block identifier (`latest`); pre-confirmed state MUST NOT feed any accept-or-settle decision, though a facilitator MAY observe pre-confirmed status while waiting for confirmation. Receipt and transaction lookups carry no block identifier — for those the equivalent requirement is the finality gate of settlement step 3 (an accepted `finality_status` plus a `block_number`). RPC 0.9 renamed the `pending` block tag to `pre_confirmed` (current nodes reject `pending`), and reading confirmed state is the safe default in any case: its staleness costs a revert (gas), never funds, since a revert rolls back the SNIP-9 nonce. + ## Settlement 1. Re-run all verification rules, including simulation. The facilitator MUST NOT trust a prior `/verify` result. -2. Execute the outside execution: cause a call to `execute_from_outside_v2(outside_execution, signature)` on `payload.from` whose on-chain caller is `extra.feePayer`. Whether the facilitator submits that `INVOKE` from its own funded account or routes it through a forwarder/[SNIP-29](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-29.md) paymaster whose forwarder contract is the announced `feePayer` is a sponsor-local implementation detail invisible to the client. -3. Wait for the transaction to reach finality status `ACCEPTED_ON_L2` (or `ACCEPTED_ON_L1`). `ACCEPTED_ON_L2` is a sequencer commitment prior to L1 finality and can, in rare cases, be reverted in an L2 reorg; waiting for `ACCEPTED_ON_L1` removes that risk at the cost of hours-scale latency. -4. Check the receipt's `execution_status` AND its effect. Settlement succeeds only if `execution_status` is `SUCCEEDED` **and** the receipt emits exactly the expected `Transfer` (exactly one `Transfer` emitted by `accepted.asset`, from `payer` to `payTo`, for exactly `accepted.amount` — the same criterion as rule 8). A `SUCCEEDED` transaction that does not emit that transfer MUST be reported as failure (`invalid_transaction_state` — the payment did not verifiably occur) — because `execute_from_outside_v2` runs inside the payer's own (possibly adversarial) account, a non-reverting transaction is not by itself proof the payment executed. A `REVERTED` transaction MUST also fail (`invalid_transaction_state`); a revert rolls back the SNIP-9 nonce, so the authorization remains valid and the facilitator SHOULD allow the same payment to be retried. If post-broadcast confirmation cannot be established (RPC failure after the transaction was submitted), the facilitator MUST NOT report success and MUST NOT report a plain terminal failure either: the transfer may still land. It MUST return the non-terminal `settlement_pending` code with the transaction hash in the `transaction` field so the caller reconciles on-chain before retrying, and, while that outcome is unresolved, MUST NOT re-broadcast the same `(payload.from, Nonce)` authorization and MUST reject repeat settlement requests for it with `duplicate_settlement`. While the guard holds, a blind retry is rejected with `duplicate_settlement`; after eviction, a retry re-enters verification, fails the minimum-remaining-window check (`outside_execution_expired`), and resolves via the consumed-nonce rule in step 5. -5. If the nonce turns out to be already consumed — settlement re-verification fails with `nonce_already_used`, a post-eviction retry fails re-verification with `outside_execution_expired`, or the settlement transaction reverts for that reason — the facilitator SHOULD locate the consuming transaction; if it executed the identical `OutsideExecution` (same hash) **and** its receipt shows `execution_status` `SUCCEEDED` and **contains** the expected `Transfer` (emitted by `accepted.asset`, from `payer` to `payTo`, for exactly `accepted.amount`; containment, not step 4's exactly-one — the consuming transaction was submitted outside this settlement attempt, so its full contents are not under this facilitator's control) — i.e., the payment itself verifiably landed on-chain — the facilitator SHOULD report `success: true` with that transaction hash, otherwise `nonce_already_used`. This makes a duplicate or concurrent settlement of the same authorization — a post-eviction retry, or a race between replicas of one facilitator sharing the announced `feePayer` — a no-op rather than a paid-but-denied outcome. Implementations that match by (payer, payTo, amount) Transfer events MUST additionally confirm the consuming transaction carries this payload's SNIP-9 nonce, so a different same-amount payment cannot be mistaken for this one. -6. Return the `SettlementResponse` with the transaction hash. RPC acceptance or pending status is not sufficient for `success: true`; the protected resource MUST only be released after the transfer has succeeded on-chain. +2. Execute the outside execution: cause a call to `execute_from_outside_v2(outside_execution, signature)` on `payload.from` whose onchain caller is `extra.feePayer`. Whether the facilitator submits that `INVOKE` from its own funded account or routes it through a forwarder/[SNIP-29](https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-29.md) paymaster whose forwarder contract is the announced `feePayer` is a sponsor-local implementation detail invisible to the client. +3. Wait for the transaction to reach finality status `ACCEPTED_ON_L2` (or `ACCEPTED_ON_L1`) and for its receipt to carry a `block_number` (under RPC ≤ 0.8 a pending receipt also reports `ACCEPTED_ON_L2` but has no `block_number`; a pending/`PRE_CONFIRMED` receipt is not yet an outcome, for a `REVERTED` status as much as for `SUCCEEDED`). `ACCEPTED_ON_L2` is a sequencer commitment prior to L1 finality and can, in rare cases, be reverted in an L2 reorg; waiting for `ACCEPTED_ON_L1` removes that risk at the cost of hours-scale latency. +4. Check the receipt's `execution_status` AND its effect. Settlement succeeds only if `execution_status` is `SUCCEEDED` **and** the receipt emits exactly the expected `Transfer` (exactly one `Transfer` emitted by `accepted.asset` whose sender is `payer`, and that `Transfer` is to `payTo` for exactly `accepted.amount` — the same payer-scoped criterion as rule 8). Payer-scoping the count is what keeps the two criteria consistent: rule 8 evaluates a trace simulated with skip-fee-charge, which carries no fee `Transfer`, while this step evaluates a real receipt, which carries one whenever `accepted.asset` is also the fee token. A `SUCCEEDED` transaction that does not emit that transfer MUST be reported as failure (`invalid_transaction_state` — the payment did not verifiably occur) — because `execute_from_outside_v2` runs inside the payer's own (possibly adversarial) account, a non-reverting transaction is not by itself proof the payment executed. A `REVERTED` transaction MUST also fail (`invalid_transaction_state`); a revert rolls back the SNIP-9 nonce, so the authorization remains valid and the facilitator SHOULD allow the same payment to be retried. If post-broadcast confirmation cannot be established (RPC failure or timeout while waiting for the receipt), the facilitator MUST NOT report success and MUST NOT report a plain terminal failure either: the transfer may still land. It MUST return the non-terminal `settlement_pending` code ([x402 v2 §9](../../x402-specification-v2.md#9-error-handling)) with the transaction hash in the `transaction` field, and MUST NOT re-broadcast the same `(payload.from, Nonce)` authorization while that outcome is unresolved; a retry of the same payload waits on that hash (steps 3–4) instead of verifying and broadcasting again, and any other repeat request is rejected with `duplicate_settlement`. If the broadcast itself times out before yielding a hash, there is no hash to return: the facilitator returns `unexpected_settle_error` with an empty `transaction` and MUST NOT re-broadcast. +5. A consumed nonce at re-verification (`nonce_already_used`) and `duplicate_settlement` are terminal; the facilitator reports `success: true` only for a transaction it broadcast itself (steps 2–4). +6. Return the `SettlementResponse` with the transaction hash. RPC acceptance or pending status is not sufficient for `success: true`; the protected resource MUST only be released after the transfer has succeeded onchain. 7. Facilitators MUST NOT leak raw paymaster/RPC exception text to clients; client-facing error text MUST be generic. On `success: false`, `payer` MUST only be included if independently verified by the facilitator. ### Facilitator `/supported` Entry -The facilitator announces the address that will be the on-chain caller in the `extra` of its `/supported` kind; resource servers copy it verbatim into `PaymentRequirements.extra.feePayer`. +The facilitator announces the address that will be the onchain caller in the `extra` of its `/supported` kind; resource servers copy it verbatim into `PaymentRequirements.extra.feePayer`. ```json { @@ -339,18 +352,18 @@ Failure: ### Vulnerability -If the same `PaymentPayload` is submitted to `/settle` twice in quick succession (retry, or a race between servers sharing a facilitator), both submissions can pass verification before the first execution consumes the SNIP-9 nonce, causing the second to revert on-chain and burn executor gas. +If the same `PaymentPayload` is submitted to `/settle` twice in quick succession (retry, or a race between servers sharing a facilitator), both submissions can pass verification before the first execution consumes the SNIP-9 nonce, causing the second to revert onchain and burn executor gas. ### Recommended Mitigation Facilitators SHOULD keep a short-lived cache keyed by `(payload.from, message.Nonce)`: -1. On `/settle`, reject immediately with `duplicate_settlement` if the key is present. +1. On `/settle`, reject immediately with `duplicate_settlement` if the key is present, unless it is the retry of the same payload that settlement step 4 waits on. 2. Insert the key before broadcasting; evict it on definitive broadcast failure or a definitively reverted execution (the revert rolls back the SNIP-9 nonce, so the authorization remains retryable) so legitimate retries are not blocked. 3. Evict entries once `Execute Before + skewMargin` has passed, after which the authorization can no longer execute even with sequencer-timestamp lag. -4. The key intentionally covers any payload sharing the nonce — only one of them can ever execute on-chain. +4. The key intentionally covers any payload sharing the nonce — only one of them can ever execute onchain. -The pre-broadcast dedup cache is an optimization only; the SNIP-9 nonce remains the authoritative replay protection. The observable requirement in settlement step 4 — never re-broadcasting an authorization whose outcome is unresolved — is normative regardless of how it is tracked. A per-process cache does not protect horizontally scaled facilitators — those need a shared store, or accept the residual race (safe on-chain; only gas is at stake). +The pre-broadcast dedup cache is an optimization only; the SNIP-9 nonce remains the authoritative replay protection. The observable requirement in settlement step 4 — never re-broadcasting an authorization whose outcome is unresolved — is normative regardless of how it is tracked. A per-process cache does not protect horizontally scaled facilitators — those need a shared store, or accept the residual race (safe onchain; only gas is at stake). ## Appendix @@ -360,7 +373,7 @@ Current versions of the major Starknet account contracts (Argent/Ready, Braavos, ### Security Considerations -- **Replay**: prevented on-chain by the single-use SNIP-9 nonce (§6), bounded by the `Execute Before` deadline (§5). +- **Replay**: prevented onchain by the single-use SNIP-9 nonce (§6), bounded by the `Execute Before` deadline (§5). - **Authorization scope**: the signature covers the exact calls, caller, nonce, and window; the single-call rule (§7) prevents call smuggling under sponsored gas. - **Submission binding**: `Caller` = `extra.feePayer` (§4) means only the sponsor can execute the authorization, so a third party that observes the signed payload cannot submit it, and cannot grief the payer's SNIP-9 nonce or burn the sponsor's gas on a guaranteed-revert transaction. This holds only when the announced `feePayer` is an address from which only the sponsor can cause a call (§9); a permissionlessly-invokable forwarder would re-open it. - **Settlement atomicity**: `execute_from_outside_v2` executes the transfer in the same transaction that consumes the nonce; a revert rolls back both. diff --git a/typescript/.changeset/stellar-inclusion-fee.md b/typescript/.changeset/stellar-inclusion-fee.md deleted file mode 100644 index 34e28b696c..0000000000 --- a/typescript/.changeset/stellar-inclusion-fee.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@x402/stellar": minor ---- - -The Stellar exact facilitator accepts an `inclusionFeeStroops` option for the settlement transaction and the fee bump. It used to always bid the 100-stroop minimum, which mainnet often does not include for Soroban transactions, so settlements could time out after a successful verify. The default stays at 100, and verify counts the configured bid against `maxTransactionFeeStroops`. diff --git a/typescript/.changeset/svm-batch-settlement.md b/typescript/.changeset/svm-batch-settlement.md new file mode 100644 index 0000000000..7ec86c5b63 --- /dev/null +++ b/typescript/.changeset/svm-batch-settlement.md @@ -0,0 +1,11 @@ +--- +"@x402/svm": minor +--- + +Added an SVM `batch-settlement` implementation for long-lived payment channels and cumulative offchain vouchers. Reuses the `upto` payment-channel primitives, adds client-signed vouchers and concurrent server-signed metering with itemized receipts, batched claim/distribution operations, payer-forced close and grace-period finalization, and onchain facilitator recovery. Ships dedicated client, server, and facilitator entry points. + +Recovery preserves signed transactions before submission, constrains postcondition reads to the confirmation slot, and reconciles actual payouts and merchant paid state without new distribution request fields. Includes replaceable recovery storage and an idempotent payout-recording callback. A broadcast whose blockhash has expired with no record of its signature is reported as `transaction_failed` and released rather than left pending, reads rejected for the confirmation-slot floor are retried, and a payout whose attribution is ambiguous is answered with `invalid_batch_settlement_svm_payout_attribution_ambiguous` and released. The facilitator signer gains an optional `isBlockhashValid` capability. + +Server-signed channels are opt-in on the client. `BatchSvmScheme` (client) takes `serverSignedChannelsPolicy`, shaped like the core spend controls: `allowedOperators` lists the operator keys that may hold the client's voucher authority, `maxDeposit` (a USD amount, default `$1`, or `false`) caps the escrow locked per channel for default assets, and `allowedAssets` opts in other tokens with atomic caps. Every deposit hint, including the server's `minDeposit`, is clamped to the cap. Trust is keyed by operator only, so it behaves the same over HTTP and MCP. Without a grant a server-signed accept is refused and the scheme's own creation-failure hook pays the same resource's client-signed accept instead; the optional `scheme.paymentPolicy` can be registered on the `x402Client` to prefer trusted metered accepts. Server-mode clients also refuse a corrective 402 whose cumulative exceeds their confirmed watermark plus their own unresolved request ceilings. On the server, a route can pin `extra.voucherSigner: "client"` next to a configured operator so one resource lists both accepts, and server-signed accepts advertise a smaller `minDeposit` hint (3x instead of 10x). The facilitator now verifies the payer proof behind `authorization` and server-mode `deposit` payloads in `/verify`. + +The facilitator advertises `extra.maxIdleSecs` (default seven days, configurable via `maxIdleSecs`, `0` disables) and its rent cleanup abandon-closes an `Open` channel at the onchain settled watermark once it has seen no facilitator-visible lifecycle activity for that long; channel records now carry `lastActivityAt`. Servers copy the window into the 402 so they know how long they have to claim. The server scheme gains `createChannelManager(facilitator, requirements, options)` for the redemption worker. Watermark reads use the manager's `rpcUrl` through `createRpcClient`. The facilitator's open-deposit simulation and rent cleanup run through the signer's RPC like every other read. diff --git a/typescript/package.json b/typescript/package.json index 86d6cc8e02..3cdaf1ea6a 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -18,7 +18,8 @@ "format:check": "turbo run format:check", "test": "turbo run test", "test:integration": "pnpm --filter @x402/core --filter @x402/evm --filter @x402/svm --filter @x402/avm --filter @x402/aptos --filter @x402/casper --filter @x402/hedera --filter @x402/keeta --filter @x402/near --filter @x402/stellar --filter @x402/xrpl --filter @x402/cardano test:integration", - "test:all": "pnpm test && pnpm test:integration" + "test:all": "pnpm test && pnpm test:integration", + "verify:exports": "node scripts/verify-package-exports.mjs" }, "keywords": [], "author": "", @@ -31,4 +32,4 @@ "turbo": "^2.5.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/typescript/packages/core/CHANGELOG.md b/typescript/packages/core/CHANGELOG.md index 5f09b8fa9d..a9914b22c6 100644 --- a/typescript/packages/core/CHANGELOG.md +++ b/typescript/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @x402/core Changelog +## 2.27.0 + +### Minor Changes + +- [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2): HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. ([#3542](https://github.com/x402-foundation/x402/pull/3542)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/core/package.json b/typescript/packages/core/package.json index 64d80691d5..945dec8914 100644 --- a/typescript/packages/core/package.json +++ b/typescript/packages/core/package.json @@ -1,12 +1,12 @@ { "name": "@x402/core", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:watch": "vitest", "test:integration": "vitest run test/integrations", diff --git a/typescript/packages/core/src/http/x402HTTPResourceServer.ts b/typescript/packages/core/src/http/x402HTTPResourceServer.ts index b07afc202f..776995dca8 100644 --- a/typescript/packages/core/src/http/x402HTTPResourceServer.ts +++ b/typescript/packages/core/src/http/x402HTTPResourceServer.ts @@ -273,6 +273,7 @@ export interface HTTPRequestContext { method: string; paymentHeader?: string; routePattern?: string; + decodedPath?: string; } /** @@ -533,7 +534,7 @@ export class x402HTTPResourceServer { const { adapter, path } = context; // Find matching route - const routeMatch = this.getRouteConfig(path, method); + const routeMatch = this.getRouteConfig(path, method, context.decodedPath); if (!routeMatch) { return { type: "no-payment-required" }; // No payment required for this route } @@ -913,7 +914,7 @@ export class x402HTTPResourceServer { */ requiresPayment(context: HTTPRequestContext): boolean { const method = context.method || context.adapter.getMethod(); - return this.getRouteConfig(context.path, method) !== undefined; + return this.getRouteConfig(context.path, method, context.decodedPath) !== undefined; } /** @@ -1045,7 +1046,11 @@ export class x402HTTPResourceServer { ): Promise { const settlementHeaders = failure.headers; const routeConfig = transportContext - ? this.getRouteConfig(transportContext.request.path, transportContext.request.method) + ? this.getRouteConfig( + transportContext.request.path, + transportContext.request.method, + transportContext.request.decodedPath, + ) : undefined; const customBody = routeConfig?.config.settlementFailedResponseBody @@ -1230,22 +1235,34 @@ export class x402HTTPResourceServer { * * @param path - Request path * @param method - HTTP method + * @param decodedPath - Framework decoded routing view, if distinct from path * @returns Route configuration and pattern, or undefined if no match */ private getRouteConfig( path: string, method: string, + decodedPath?: string, ): { config: RouteConfig; pattern: string } | undefined { - const normalizedPath = this.normalizePath(path); const upperMethod = method.toUpperCase(); - const matchingRoute = this.compiledRoutes.find( - route => - route.regex.test(normalizedPath) && (route.verb === "*" || route.verb === upperMethod), - ); + const findMatch = (candidate: string): { config: RouteConfig; pattern: string } | undefined => { + const matchingRoute = this.compiledRoutes.find( + route => route.regex.test(candidate) && (route.verb === "*" || route.verb === upperMethod), + ); + if (!matchingRoute) return undefined; + return { config: matchingRoute.config, pattern: matchingRoute.pattern }; + }; + + const match = findMatch(this.normalizePath(path)); + if (match !== undefined) { + return match; + } + + if (decodedPath !== undefined && decodedPath !== path) { + return findMatch(this.normalizeDecodedPath(decodedPath)); + } - if (!matchingRoute) return undefined; - return { config: matchingRoute.config, pattern: matchingRoute.pattern }; + return undefined; } /** @@ -1416,6 +1433,17 @@ export class x402HTTPResourceServer { return normalized.replace(/\/+/g, "/").replace(/(.+?)\/+$/, "$1"); } + /** + * Normalize a framework-decoded path without re-decoding percent-escapes. + * + * @param path - Framework-decoded path + * @returns Normalized path + */ + private normalizeDecodedPath(path: string): string { + const pathWithoutQuery = path.split(/[?#]/)[0]; + return pathWithoutQuery.replace(/\/+/g, "/").replace(/(.+?)\/+$/, "$1") || "/"; + } + /** * Generate paywall HTML for browser requests * diff --git a/typescript/packages/core/test/unit/http/routeMatching.test.ts b/typescript/packages/core/test/unit/http/routeMatching.test.ts new file mode 100644 index 0000000000..2160c94929 --- /dev/null +++ b/typescript/packages/core/test/unit/http/routeMatching.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from "vitest"; +import { + x402HTTPResourceServer, + HTTPRequestContext, + HTTPAdapter, +} from "../../../src/http/x402HTTPResourceServer"; +import { x402ResourceServer } from "../../../src/server/x402ResourceServer"; + +class StubAdapter implements HTTPAdapter { + getHeader(): string | undefined { + return undefined; + } + getMethod(): string { + return "GET"; + } + getPath(): string { + return "/"; + } + getUrl(): string { + return "http://localhost/"; + } + getAcceptHeader(): string { + return ""; + } + getUserAgent(): string { + return ""; + } +} + +type DecodedPathNormalizer = { + normalizeDecodedPath: (path: string) => string; +}; + +function context(path: string, method: string = "GET", decodedPath?: string): HTTPRequestContext { + return { + adapter: new StubAdapter(), + path, + method, + decodedPath, + }; +} + +describe("normalizeDecodedPath", () => { + const server = new x402HTTPResourceServer({} as x402ResourceServer, { + "GET /api/premium": { accepts: [] }, + }); + const normalizeDecodedPath = (path: string): string => + (server as unknown as DecodedPathNormalizer).normalizeDecodedPath(path); + + it.each([ + ["/api", "/api"], + ["/api/", "/api"], + ["/api//users", "/api/users"], + ["/api?query=1", "/api"], + ["/api#fragment", "/api"], + ["", "/"], + ["/api/premium", "/api/premium"], + ])("normalizes %s to %s", (inputPath, expected) => { + expect(normalizeDecodedPath(inputPath)).toBe(expected); + }); + + it("does not re-decode percent-escapes", () => { + expect(normalizeDecodedPath("/api/x%41")).toBe("/api/x%41"); + }); +}); + +describe("decoded path divergence bypass", () => { + const server = (pattern: string = "GET /api/premium"): x402HTTPResourceServer => + new x402HTTPResourceServer({} as x402ResourceServer, { + [pattern]: { accepts: [] }, + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "literal route requires payment when decoded path matches %s", + escapedPath => { + expect(server().requiresPayment(context(escapedPath, "GET", "/api/premium"))).toBe(true); + }, + ); + + it("literal route misses without decoded path", () => { + expect(server().requiresPayment(context("/api%2Fpremium", "GET", undefined))).toBe(false); + }); + + it("real extra segment still not matched", () => { + const httpServer = server("GET /api/users/:id"); + expect(httpServer.requiresPayment(context("/api/users/x/y", "GET", "/api/users/x/y"))).toBe( + false, + ); + }); + + it("unrelated decoded path does not require payment", () => { + expect(server().requiresPayment(context("/public/report", "GET", "/public/report"))).toBe( + false, + ); + }); +}); diff --git a/typescript/packages/core/tsup.config.ts b/typescript/packages/core/tsup.config.ts index f4efd261b4..2a7904cdf5 100644 --- a/typescript/packages/core/tsup.config.ts +++ b/typescript/packages/core/tsup.config.ts @@ -31,5 +31,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/extensions/CHANGELOG.md b/typescript/packages/extensions/CHANGELOG.md index 8098d5e82a..eeb3fdd7cd 100644 --- a/typescript/packages/extensions/CHANGELOG.md +++ b/typescript/packages/extensions/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/extensions Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/extensions/package.json b/typescript/packages/extensions/package.json index 7952f88c13..d734a27944 100644 --- a/typescript/packages/extensions/package.json +++ b/typescript/packages/extensions/package.json @@ -1,12 +1,12 @@ { "name": "@x402/extensions", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:watch": "vitest", "watch": "tsc --watch", diff --git a/typescript/packages/extensions/tsup.config.ts b/typescript/packages/extensions/tsup.config.ts index fe76c754d1..32ec86a4f8 100644 --- a/typescript/packages/extensions/tsup.config.ts +++ b/typescript/packages/extensions/tsup.config.ts @@ -28,5 +28,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/axios/CHANGELOG.md b/typescript/packages/http/axios/CHANGELOG.md index ffb0af8daa..3aae827eeb 100644 --- a/typescript/packages/http/axios/CHANGELOG.md +++ b/typescript/packages/http/axios/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/axios Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/axios/package.json b/typescript/packages/http/axios/package.json index b7795ed41b..85bcc99764 100644 --- a/typescript/packages/http/axios/package.json +++ b/typescript/packages/http/axios/package.json @@ -1,6 +1,6 @@ { "name": "@x402/axios", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/axios/tsup.config.ts b/typescript/packages/http/axios/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/http/axios/tsup.config.ts +++ b/typescript/packages/http/axios/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/express/CHANGELOG.md b/typescript/packages/http/express/CHANGELOG.md index 5c189ef137..692b5ff312 100644 --- a/typescript/packages/http/express/CHANGELOG.md +++ b/typescript/packages/http/express/CHANGELOG.md @@ -1,5 +1,18 @@ # @x402/express Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + - @x402/extensions@2.27.0 + - @x402/paywall@2.27.0 + +### Patch Changes + +- [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2): HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. ([#3542](https://github.com/x402-foundation/x402/pull/3542)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/express/package.json b/typescript/packages/http/express/package.json index 23d19be566..e4a2f4cb61 100644 --- a/typescript/packages/http/express/package.json +++ b/typescript/packages/http/express/package.json @@ -1,6 +1,6 @@ { "name": "@x402/express", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/express/src/encodedPathBypass.test.ts b/typescript/packages/http/express/src/encodedPathBypass.test.ts index 6c770335a7..3cad2bdf4d 100644 --- a/typescript/packages/http/express/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/express/src/encodedPathBypass.test.ts @@ -179,3 +179,55 @@ describe("express end-to-end: trailing wildcard route prefix", () => { expect(await statusFor(port, "/health")).toBe(200); }); }); + +describe("express end-to-end: literal route percent-encoded separator", () => { + let server: Server; + let port: number; + + beforeAll(async () => { + const app = express(); + const resourceServer = await buildTestResourceServer(); + app.use( + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: TEST_NETWORK, + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", (_req, res) => res.status(200).send("paid content")); + + server = app.listen(0); + await new Promise(resolve => server.once("listening", () => resolve())); + port = (server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); + }); + + it("returns 402 for the baseline literal route", async () => { + expect(await statusFor(port, "/api/premium")).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + expect(await statusFor(port, path)).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + expect(await statusFor(port, "/health")).toBe(404); + }); +}); diff --git a/typescript/packages/http/express/src/index.ts b/typescript/packages/http/express/src/index.ts index d997a0307f..77d64f3826 100644 --- a/typescript/packages/http/express/src/index.ts +++ b/typescript/packages/http/express/src/index.ts @@ -65,6 +65,20 @@ function sendInternalError(res: Response, error: unknown): void { res.status(500).json({ error: "Internal Server Error" }); } +/** + * Decode percent-escapes in a request path. + * + * @param path - Request path + * @returns Decoded path, or the original if decoding fails + */ +function decodedRoutePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Express payment middleware for x402 protocol (direct HTTP server instance). * @@ -153,9 +167,11 @@ export function paymentMiddlewareFromHTTPServer( return async (req: Request, res: Response, next: NextFunction) => { // Create adapter and context const adapter = new ExpressAdapter(req); + const path = req.path; const context: HTTPRequestContext = { adapter, - path: req.path, + path, + decodedPath: decodedRoutePath(path), method: req.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), }; diff --git a/typescript/packages/http/express/tsup.config.ts b/typescript/packages/http/express/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/http/express/tsup.config.ts +++ b/typescript/packages/http/express/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/fastify/CHANGELOG.md b/typescript/packages/http/fastify/CHANGELOG.md index 1ea070bb1c..b93c542f46 100644 --- a/typescript/packages/http/fastify/CHANGELOG.md +++ b/typescript/packages/http/fastify/CHANGELOG.md @@ -1,5 +1,18 @@ # @x402/fastify +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + - @x402/extensions@2.27.0 + - @x402/paywall@2.27.0 + +### Patch Changes + +- [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2): HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. ([#3542](https://github.com/x402-foundation/x402/pull/3542)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/fastify/package.json b/typescript/packages/http/fastify/package.json index dcf83cffe9..fdba69829e 100644 --- a/typescript/packages/http/fastify/package.json +++ b/typescript/packages/http/fastify/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fastify", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/fastify/src/index.ts b/typescript/packages/http/fastify/src/index.ts index ae3b004034..1bfbea6778 100644 --- a/typescript/packages/http/fastify/src/index.ts +++ b/typescript/packages/http/fastify/src/index.ts @@ -229,6 +229,20 @@ function sendInternalError(reply: FastifyReply, error: unknown): void { reply.status(500).send({ error: "Internal Server Error" }); } +/** + * Decode percent-escapes in a request path. + * + * @param path - Request path + * @returns Decoded path, or the original if decoding fails + */ +function decodedRoutePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Configuration for registering a payment scheme with a specific network. */ @@ -336,6 +350,7 @@ export function paymentMiddlewareFromHTTPServer( const context: HTTPRequestContext = { adapter, path, + decodedPath: decodedRoutePath(path), method: request.method, paymentHeader: (request.headers["payment-signature"] as string | undefined) || diff --git a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts index 6d5892a49e..495f9faa91 100644 --- a/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts +++ b/typescript/packages/http/fastify/src/lineTerminatorBypass.test.ts @@ -130,3 +130,52 @@ describe("fastify end-to-end: percent-encoded line terminator under wildcard rou expect(await statusFor(port, "/health")).toBe(200); }); }); + +describe("fastify end-to-end: literal route percent-encoded separator", () => { + let app: FastifyInstance; + let port: number; + + beforeAll(async () => { + app = Fastify(); + const resourceServer = await buildTestResourceServer(); + paymentMiddleware( + app, + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: TEST_NETWORK, + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ); + app.get("/api/premium", async () => "paid content"); + await app.listen({ host: "127.0.0.1", port: 0 }); + port = (app.server.address() as AddressInfo).port; + }); + + afterAll(async () => { + await app.close(); + }); + + it("returns 402 for the baseline literal route", async () => { + expect(await statusFor(port, "/api/premium")).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + expect(await statusFor(port, path)).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + expect(await statusFor(port, "/health")).toBe(404); + }); +}); diff --git a/typescript/packages/http/fastify/tsup.config.ts b/typescript/packages/http/fastify/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/http/fastify/tsup.config.ts +++ b/typescript/packages/http/fastify/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/fetch/CHANGELOG.md b/typescript/packages/http/fetch/CHANGELOG.md index ccd89d6ed2..7bf0bfd658 100644 --- a/typescript/packages/http/fetch/CHANGELOG.md +++ b/typescript/packages/http/fetch/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/fetch Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/fetch/package.json b/typescript/packages/http/fetch/package.json index 02e1961053..d9fd4cf845 100644 --- a/typescript/packages/http/fetch/package.json +++ b/typescript/packages/http/fetch/package.json @@ -1,6 +1,6 @@ { "name": "@x402/fetch", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/fetch/tsup.config.ts b/typescript/packages/http/fetch/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/http/fetch/tsup.config.ts +++ b/typescript/packages/http/fetch/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/hono/CHANGELOG.md b/typescript/packages/http/hono/CHANGELOG.md index f3b6f1e45b..184a98b096 100644 --- a/typescript/packages/http/hono/CHANGELOG.md +++ b/typescript/packages/http/hono/CHANGELOG.md @@ -1,5 +1,18 @@ # @x402/hono Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + - @x402/extensions@2.27.0 + - @x402/paywall@2.27.0 + +### Patch Changes + +- [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2): HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. ([#3542](https://github.com/x402-foundation/x402/pull/3542)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/hono/package.json b/typescript/packages/http/hono/package.json index 100fd2f546..ba682a91af 100644 --- a/typescript/packages/http/hono/package.json +++ b/typescript/packages/http/hono/package.json @@ -1,6 +1,6 @@ { "name": "@x402/hono", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/hono/src/encodedPathBypass.test.ts b/typescript/packages/http/hono/src/encodedPathBypass.test.ts index 132505a9de..28e26fddf6 100644 --- a/typescript/packages/http/hono/src/encodedPathBypass.test.ts +++ b/typescript/packages/http/hono/src/encodedPathBypass.test.ts @@ -91,3 +91,140 @@ describe("hono end-to-end: encoded path separator in a :param segment", () => { expect(res.status).toBe(200); }); }); + +describe("hono end-to-end: literal route percent-encoded separator", () => { + /** + * Literal paid `/api/premium` app. + * + * @returns Hono app with payment middleware + */ + async function buildLiteralApp() { + const app = new Hono(); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:84532" }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: "eip155:84532" }), + }); + resourceServer.register("eip155:84532", { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + app.use( + "*", + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: "eip155:84532", + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", c => c.json({ secret: "paid content" })); + return app; + } + + it("returns 402 for the baseline literal route", async () => { + const res = await (await buildLiteralApp()).request("/api/premium"); + expect(res.status).toBe(402); + }); + + it.each([["/api%2Fpremium"], ["/api%2fpremium"], ["/%61pi%2Fpremium"]])( + "returns 402 for percent-encoded separator %s", + async path => { + const res = await (await buildLiteralApp()).request(path); + expect(res.status).toBe(402); + }, + ); + + it("does not gate an unrelated path", async () => { + const res = await (await buildLiteralApp()).request("/health"); + expect(res.status).toBe(404); + }); +}); + +describe("hono end-to-end: literal route basePath", () => { + /** + * Paid `/api/premium` app mounted at `/svc`. + * + * @returns Hono app with payment middleware + */ + async function buildMountedApp() { + const app = new Hono().basePath("/svc"); + const resourceServer = new x402ResourceServer({ + getSupported: async () => ({ + kinds: [{ x402Version: 2, scheme: "exact", network: "eip155:84532" }], + extensions: [], + signers: {}, + }), + verify: async () => ({ isValid: true }), + settle: async () => ({ success: true, transaction: "", network: "eip155:84532" }), + }); + resourceServer.register("eip155:84532", { + scheme: "exact", + parsePrice: async () => ({ + amount: "1000000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + }), + enhancePaymentRequirements: async paymentRequirements => paymentRequirements, + defaultAssetTransferMethod: "default", + paymentFlows: { default: { supported: ["upfront"], default: "upfront" } }, + }); + await resourceServer.initialize(); + app.use( + "*", + paymentMiddleware( + { + "GET /api/premium": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$0.01", + network: "eip155:84532", + }, + }, + }, + resourceServer, + undefined, + undefined, + false, + ), + ); + app.get("/api/premium", c => c.json({ secret: "paid content" })); + return app; + } + + it("returns 402 for the mounted literal route", async () => { + const res = await (await buildMountedApp()).request("/svc/api/premium"); + expect(res.status).toBe(402); + }); + + it.each([["/svc/api%2Fpremium"], ["/svc/api%2fpremium"]])( + "returns 402 for mounted percent-encoded separator %s", + async path => { + const res = await (await buildMountedApp()).request(path); + expect(res.status).toBe(402); + }, + ); +}); diff --git a/typescript/packages/http/hono/src/index.ts b/typescript/packages/http/hono/src/index.ts index 7c56f277d5..aa575f4fcd 100644 --- a/typescript/packages/http/hono/src/index.ts +++ b/typescript/packages/http/hono/src/index.ts @@ -16,6 +16,7 @@ import { } from "@x402/core/server"; import { SchemeNetworkServer, Network } from "@x402/core/types"; import { Context, MiddlewareHandler } from "hono"; +import { basePath } from "hono/route"; import { HonoAdapter } from "./adapter"; /** @@ -67,6 +68,37 @@ function internalErrorResponse(c: Context, error: unknown): Response { return c.json({ error: "Internal Server Error" }, 500); } +/** + * Decode `c.req.path` and strip the Hono `basePath` mount prefix. + * + * @param c - Hono context + * @returns Decoded path relative to the app mount + */ +function decodedRoutePath(c: Context): string { + let path: string; + try { + path = decodeURIComponent(c.req.path); + } catch { + path = c.req.path; + } + let rootPath = ""; + try { + rootPath = basePath(c); + } catch { + return path; + } + if (!rootPath || rootPath === "/" || !path.startsWith(rootPath)) { + return path; + } + if (path === rootPath) { + return ""; + } + if (path[rootPath.length] === "/") { + return path.slice(rootPath.length); + } + return path; +} + /** * Hono payment middleware for x402 protocol (direct HTTP server instance). * @@ -155,9 +187,11 @@ export function paymentMiddlewareFromHTTPServer( return async (c: Context, next: () => Promise) => { // Create adapter and context const adapter = new HonoAdapter(c); + const path = c.req.path; const context: HTTPRequestContext = { adapter, - path: c.req.path, + path, + decodedPath: decodedRoutePath(c), method: c.req.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), }; diff --git a/typescript/packages/http/hono/tsup.config.ts b/typescript/packages/http/hono/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/http/hono/tsup.config.ts +++ b/typescript/packages/http/hono/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/next/CHANGELOG.md b/typescript/packages/http/next/CHANGELOG.md index ddfb1dc77d..6985fae26c 100644 --- a/typescript/packages/http/next/CHANGELOG.md +++ b/typescript/packages/http/next/CHANGELOG.md @@ -1,5 +1,18 @@ # @x402/next Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + - @x402/extensions@2.27.0 + - @x402/paywall@2.27.0 + +### Patch Changes + +- [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2): HTTP resource servers now match protected routes against both the escaped request path and the framework's decoded routing view, requiring payment if either matches. A literal route such as `GET /api/premium` could previously be reached unpaid by encoding its path separator (`/api%2Fpremium`) when the adapter only consulted the escaped path while the framework dispatched on the decoded one. ([#3542](https://github.com/x402-foundation/x402/pull/3542)) - Thanks [@PhilBot402](https://github.com/PhilBot402) and [@phdargen](https://github.com/phdargen)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/next/package.json b/typescript/packages/http/next/package.json index 647e6cc9a4..037b81cabc 100644 --- a/typescript/packages/http/next/package.json +++ b/typescript/packages/http/next/package.json @@ -1,6 +1,6 @@ { "name": "@x402/next", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -8,7 +8,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/next/src/utils.test.ts b/typescript/packages/http/next/src/utils.test.ts index d3fc988a60..f4c643c7e7 100644 --- a/typescript/packages/http/next/src/utils.test.ts +++ b/typescript/packages/http/next/src/utils.test.ts @@ -210,6 +210,22 @@ describe("createRequestContext", () => { expect(context.adapter).toBeDefined(); }); + it("sets decodedPath when the request path encodes a separator", () => { + const req = createMockRequest({ url: "https://example.com/api%2Fpremium" }); + + const context = createRequestContext(req); + + expect(context.decodedPath).toBe("/api/premium"); + }); + + it("keeps decodedPath unchanged when percent-encoding is malformed", () => { + const req = createMockRequest({ url: "https://example.com/%E0%A4%A" }); + + const context = createRequestContext(req); + + expect(context.decodedPath).toBe(context.path); + }); + it("extracts x-payment header", () => { const req = createMockRequest({ headers: { "X-Payment": "payment-data" } }); diff --git a/typescript/packages/http/next/src/utils.ts b/typescript/packages/http/next/src/utils.ts index 2b2f2d7aa3..8d4a895895 100644 --- a/typescript/packages/http/next/src/utils.ts +++ b/typescript/packages/http/next/src/utils.ts @@ -54,6 +54,20 @@ export function createInternalErrorResponse(error: unknown): NextResponse { }); } +/** + * Decode percent-escapes in a request path. + * + * @param path - Request path + * @returns Decoded path, or the original if decoding fails + */ +function decodedRoutePath(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + /** * Prepares an existing x402HTTPResourceServer with initialization logic * @@ -137,9 +151,11 @@ export function createHttpServer( export function createRequestContext(request: NextRequest): HTTPRequestContext { // Create adapter and context const adapter = new NextAdapter(request); + const path = request.nextUrl.pathname; return { adapter, - path: request.nextUrl.pathname, + path, + decodedPath: decodedRoutePath(path), method: request.method, paymentHeader: adapter.getHeader("payment-signature") || adapter.getHeader("x-payment"), }; diff --git a/typescript/packages/http/next/tsup.config.ts b/typescript/packages/http/next/tsup.config.ts index 7ac9e3a6b9..95e117af3a 100644 --- a/typescript/packages/http/next/tsup.config.ts +++ b/typescript/packages/http/next/tsup.config.ts @@ -24,5 +24,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/http/paywall/CHANGELOG.md b/typescript/packages/http/paywall/CHANGELOG.md index 23d50dbf26..ed41311c00 100644 --- a/typescript/packages/http/paywall/CHANGELOG.md +++ b/typescript/packages/http/paywall/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/paywall Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/http/paywall/package.json b/typescript/packages/http/paywall/package.json index d50353cd41..ceb45c0171 100644 --- a/typescript/packages/http/paywall/package.json +++ b/typescript/packages/http/paywall/package.json @@ -1,6 +1,6 @@ { "name": "@x402/paywall", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/index.d.ts", @@ -9,7 +9,7 @@ "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "build:paywall": "tsx src/evm/build.ts && tsx src/svm/build.ts && tsx src/avm/build.ts", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/http/paywall/tsup.config.ts b/typescript/packages/http/paywall/tsup.config.ts index 0a85785c96..c7be6098e6 100644 --- a/typescript/packages/http/paywall/tsup.config.ts +++ b/typescript/packages/http/paywall/tsup.config.ts @@ -28,5 +28,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mcp/CHANGELOG.md b/typescript/packages/mcp/CHANGELOG.md index 984ff8adfb..ab0d320b28 100644 --- a/typescript/packages/mcp/CHANGELOG.md +++ b/typescript/packages/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/mcp Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mcp/package.json b/typescript/packages/mcp/package.json index 4cc4645a75..3fb46c5891 100644 --- a/typescript/packages/mcp/package.json +++ b/typescript/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@x402/mcp", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", @@ -9,7 +9,7 @@ "test": "vitest run --coverage", "test:integration": "vitest run --dir test/integration", "test:watch": "vitest", - "build": "tsup", + "build": "tsup && node ../../scripts/mirror-cjs-dts.mjs", "watch": "tsc --watch", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/mcp/tsup.config.ts b/typescript/packages/mcp/tsup.config.ts index f8699f925c..d5aec66aee 100644 --- a/typescript/packages/mcp/tsup.config.ts +++ b/typescript/packages/mcp/tsup.config.ts @@ -23,5 +23,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/aptos/CHANGELOG.md b/typescript/packages/mechanisms/aptos/CHANGELOG.md index 00d7d23a09..b7da44217a 100644 --- a/typescript/packages/mechanisms/aptos/CHANGELOG.md +++ b/typescript/packages/mechanisms/aptos/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/aptos +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/aptos/package.json b/typescript/packages/mechanisms/aptos/package.json index 7949b49ccb..1251a446f8 100644 --- a/typescript/packages/mechanisms/aptos/package.json +++ b/typescript/packages/mechanisms/aptos/package.json @@ -1,12 +1,12 @@ { "name": "@x402/aptos", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/aptos/tsup.config.ts b/typescript/packages/mechanisms/aptos/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/aptos/tsup.config.ts +++ b/typescript/packages/mechanisms/aptos/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/avm/CHANGELOG.md b/typescript/packages/mechanisms/avm/CHANGELOG.md index 2a4ef5ded6..687fa9af61 100644 --- a/typescript/packages/mechanisms/avm/CHANGELOG.md +++ b/typescript/packages/mechanisms/avm/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/avm +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/avm/package.json b/typescript/packages/mechanisms/avm/package.json index a61e643cc9..138b837575 100644 --- a/typescript/packages/mechanisms/avm/package.json +++ b/typescript/packages/mechanisms/avm/package.json @@ -1,12 +1,12 @@ { "name": "@x402/avm", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/avm/tsup.config.ts b/typescript/packages/mechanisms/avm/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/avm/tsup.config.ts +++ b/typescript/packages/mechanisms/avm/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/cardano/CHANGELOG.md b/typescript/packages/mechanisms/cardano/CHANGELOG.md index f00df4a855..6ecf77c47d 100644 --- a/typescript/packages/mechanisms/cardano/CHANGELOG.md +++ b/typescript/packages/mechanisms/cardano/CHANGELOG.md @@ -1,6 +1,12 @@ # @x402/cardano +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 - Implement x402 v2 protocol support for the Cardano mechanism (exact scheme). - diff --git a/typescript/packages/mechanisms/cardano/package.json b/typescript/packages/mechanisms/cardano/package.json index ad7d818384..7b114ea7d7 100644 --- a/typescript/packages/mechanisms/cardano/package.json +++ b/typescript/packages/mechanisms/cardano/package.json @@ -1,11 +1,11 @@ { "name": "@x402/cardano", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "start": "tsx --env-file=.env index.ts", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", diff --git a/typescript/packages/mechanisms/cardano/tsup.config.ts b/typescript/packages/mechanisms/cardano/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/cardano/tsup.config.ts +++ b/typescript/packages/mechanisms/cardano/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/casper/CHANGELOG.md b/typescript/packages/mechanisms/casper/CHANGELOG.md index 28471d84d9..27f3d2a7bd 100644 --- a/typescript/packages/mechanisms/casper/CHANGELOG.md +++ b/typescript/packages/mechanisms/casper/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/casper +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 - [f4c3f61](https://github.com/x402-foundation/x402/commit/f4c3f61): Add TypeScript support for the Casper exact payment mechanism. ([#2877](https://github.com/x402-foundation/x402/pull/2877)) - Thanks [@davidatwhiletrue](https://github.com/davidatwhiletrue)! diff --git a/typescript/packages/mechanisms/casper/package.json b/typescript/packages/mechanisms/casper/package.json index ee0ac8ec3d..1f92ea619f 100644 --- a/typescript/packages/mechanisms/casper/package.json +++ b/typescript/packages/mechanisms/casper/package.json @@ -1,12 +1,12 @@ { "name": "@x402/casper", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/casper/tsup.config.ts b/typescript/packages/mechanisms/casper/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/casper/tsup.config.ts +++ b/typescript/packages/mechanisms/casper/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/concordium/CHANGELOG.md b/typescript/packages/mechanisms/concordium/CHANGELOG.md index 4cf84b970d..21bbf633fb 100644 --- a/typescript/packages/mechanisms/concordium/CHANGELOG.md +++ b/typescript/packages/mechanisms/concordium/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/concordium Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/concordium/package.json b/typescript/packages/mechanisms/concordium/package.json index 5c16298354..ad85e4c594 100644 --- a/typescript/packages/mechanisms/concordium/package.json +++ b/typescript/packages/mechanisms/concordium/package.json @@ -1,12 +1,12 @@ { "name": "@x402/concordium", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/concordium/tsup.config.ts b/typescript/packages/mechanisms/concordium/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/concordium/tsup.config.ts +++ b/typescript/packages/mechanisms/concordium/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/evm/CHANGELOG.md b/typescript/packages/mechanisms/evm/CHANGELOG.md index 4610b3e5d3..0301c2a5c8 100644 --- a/typescript/packages/mechanisms/evm/CHANGELOG.md +++ b/typescript/packages/mechanisms/evm/CHANGELOG.md @@ -1,5 +1,16 @@ # @x402/evm Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + +### Patch Changes + +- [59f1347](https://github.com/x402-foundation/x402/commit/59f1347): `getEvmChainId` now requires a bare decimal CAIP-2 reference (`eip155:`). It used to call `parseInt`, which stops at the first non-digit, so `eip155:0x2105` resolved to chain ID `0` and `eip155:8453abc` to `8453` instead of being rejected — and that chain ID is signed into the EIP-712 domain for Permit2, EIP-3009 and batch-settlement. Chain IDs beyond `Number.MAX_SAFE_INTEGER` are now rejected instead of silently rounded. The Go and Python SDKs already reject all of these. ([#3521](https://github.com/x402-foundation/x402/pull/3521)) - Thanks [@HereForTheTechNFT](https://github.com/HereForTheTechNFT)! + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index 63e355095f..12bf374f30 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -1,12 +1,12 @@ { "name": "@x402/evm", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "setup:smart-accounts": "tsx scripts/setup-smart-accounts.ts", diff --git a/typescript/packages/mechanisms/evm/src/utils.ts b/typescript/packages/mechanisms/evm/src/utils.ts index e518dabec6..6e5e8ce9a5 100644 --- a/typescript/packages/mechanisms/evm/src/utils.ts +++ b/typescript/packages/mechanisms/evm/src/utils.ts @@ -1,6 +1,9 @@ import type { Network, SettleResponse } from "@x402/core/types"; import { isHash, toHex } from "viem"; +/** CAIP-2 `eip155` reference: a bare decimal chain ID, matching the Go and Python SDKs. */ +const EIP155_NETWORK_REGEX = /^eip155:(\d+)$/; + /** * Extract chain ID from a CAIP-2 network identifier (eip155:CHAIN_ID). * @@ -9,16 +12,21 @@ import { isHash, toHex } from "viem"; * @throws Error if the network format is invalid */ export function getEvmChainId(network: string): number { - if (network.startsWith("eip155:")) { - const idStr = network.split(":")[1]; - const chainId = parseInt(idStr, 10); - if (isNaN(chainId)) { - throw new Error(`Invalid CAIP-2 chain ID: ${network}`); - } - return chainId; + if (!network.startsWith("eip155:")) { + throw new Error(`Unsupported network format: ${network} (expected eip155:CHAIN_ID)`); + } + + const match = EIP155_NETWORK_REGEX.exec(network); + if (!match) { + throw new Error(`Invalid CAIP-2 chain ID: ${network}`); + } + + const chainId = Number(match[1]); + if (!Number.isSafeInteger(chainId)) { + throw new Error(`Invalid CAIP-2 chain ID: ${network}`); } - throw new Error(`Unsupported network format: ${network} (expected eip155:CHAIN_ID)`); + return chainId; } /** diff --git a/typescript/packages/mechanisms/evm/test/unit/utils.test.ts b/typescript/packages/mechanisms/evm/test/unit/utils.test.ts index 04b3a045c1..7f6bd24e20 100644 --- a/typescript/packages/mechanisms/evm/test/unit/utils.test.ts +++ b/typescript/packages/mechanisms/evm/test/unit/utils.test.ts @@ -43,6 +43,25 @@ describe("EVM Utils", () => { it("should throw for invalid CAIP-2 chain ID", () => { expect(() => getEvmChainId("eip155:abc")).toThrow("Invalid CAIP-2 chain ID"); }); + + // parseInt() stops at the first non-digit, so these used to resolve to a chain ID the + // caller never asked for (notably "eip155:0x2105" -> 0) and get signed into an EIP-712 + // domain. The Go and Python SDKs reject all of them. + it.each([ + "eip155:0x2105", + "eip155:8453abc", + "eip155:8453.9", + "eip155:1e3", + "eip155:+8453", + "eip155: 8453", + "eip155:8453:extra", + ])("should throw for malformed CAIP-2 reference %j", network => { + expect(() => getEvmChainId(network)).toThrow("Invalid CAIP-2 chain ID"); + }); + + it("should throw when the chain ID exceeds Number precision", () => { + expect(() => getEvmChainId("eip155:9007199254740993")).toThrow("Invalid CAIP-2 chain ID"); + }); }); describe("getEvmChainIdV1 (legacy names)", () => { diff --git a/typescript/packages/mechanisms/evm/tsup.config.ts b/typescript/packages/mechanisms/evm/tsup.config.ts index c73fc63ebf..78836c236f 100644 --- a/typescript/packages/mechanisms/evm/tsup.config.ts +++ b/typescript/packages/mechanisms/evm/tsup.config.ts @@ -39,5 +39,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/hedera/CHANGELOG.md b/typescript/packages/mechanisms/hedera/CHANGELOG.md index a83204bc4c..d7eb0cbb18 100644 --- a/typescript/packages/mechanisms/hedera/CHANGELOG.md +++ b/typescript/packages/mechanisms/hedera/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/hedera/package.json b/typescript/packages/mechanisms/hedera/package.json index bbcdc5db3d..8fad265971 100644 --- a/typescript/packages/mechanisms/hedera/package.json +++ b/typescript/packages/mechanisms/hedera/package.json @@ -1,11 +1,11 @@ { "name": "@x402/hedera", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/hedera/tsup.config.ts b/typescript/packages/mechanisms/hedera/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/hedera/tsup.config.ts +++ b/typescript/packages/mechanisms/hedera/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/keeta/CHANGELOG.md b/typescript/packages/mechanisms/keeta/CHANGELOG.md index 95144f67bc..d9dea10aa8 100644 --- a/typescript/packages/mechanisms/keeta/CHANGELOG.md +++ b/typescript/packages/mechanisms/keeta/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/keeta +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/keeta/package.json b/typescript/packages/mechanisms/keeta/package.json index 09a2f7493f..d4ef7ecdd1 100644 --- a/typescript/packages/mechanisms/keeta/package.json +++ b/typescript/packages/mechanisms/keeta/package.json @@ -1,12 +1,12 @@ { "name": "@x402/keeta", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/keeta/tsup.config.ts b/typescript/packages/mechanisms/keeta/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/keeta/tsup.config.ts +++ b/typescript/packages/mechanisms/keeta/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/near/CHANGELOG.md b/typescript/packages/mechanisms/near/CHANGELOG.md index 3ecdfc4659..d004126cff 100644 --- a/typescript/packages/mechanisms/near/CHANGELOG.md +++ b/typescript/packages/mechanisms/near/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/near +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/near/package.json b/typescript/packages/mechanisms/near/package.json index d001e12da2..932c4ede6b 100644 --- a/typescript/packages/mechanisms/near/package.json +++ b/typescript/packages/mechanisms/near/package.json @@ -1,11 +1,11 @@ { "name": "@x402/near", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/near/tsup.config.ts b/typescript/packages/mechanisms/near/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/near/tsup.config.ts +++ b/typescript/packages/mechanisms/near/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/stellar/CHANGELOG.md b/typescript/packages/mechanisms/stellar/CHANGELOG.md index 2050ce6b52..05a37c3604 100644 --- a/typescript/packages/mechanisms/stellar/CHANGELOG.md +++ b/typescript/packages/mechanisms/stellar/CHANGELOG.md @@ -1,5 +1,13 @@ # @x402/stellar Changelog +## 2.27.0 + +### Minor Changes + +- [c8c71f2](https://github.com/x402-foundation/x402/commit/c8c71f2): The Stellar exact facilitator accepts an `inclusionFeeStroops` option for the settlement transaction and the fee bump. It used to always bid the 100-stroop minimum, which mainnet often does not include for Soroban transactions, so settlements could time out after a successful verify. The default stays at 100, and verify counts the configured bid against `maxTransactionFeeStroops`. ([#3503](https://github.com/x402-foundation/x402/pull/3503)) - Thanks [@DiegoPoveda01](https://github.com/DiegoPoveda01) and [@claude](https://github.com/claude)! +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/stellar/package.json b/typescript/packages/mechanisms/stellar/package.json index bc4c90f4d1..df2bea846a 100644 --- a/typescript/packages/mechanisms/stellar/package.json +++ b/typescript/packages/mechanisms/stellar/package.json @@ -1,12 +1,12 @@ { "name": "@x402/stellar", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:all": "vitest run --config vitest.config.ts && vitest run --config vitest.integration.config.ts", diff --git a/typescript/packages/mechanisms/stellar/tsup.config.ts b/typescript/packages/mechanisms/stellar/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/stellar/tsup.config.ts +++ b/typescript/packages/mechanisms/stellar/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/svm/CHANGELOG.md b/typescript/packages/mechanisms/svm/CHANGELOG.md index 4116ce533b..04f0ecbb6e 100644 --- a/typescript/packages/mechanisms/svm/CHANGELOG.md +++ b/typescript/packages/mechanisms/svm/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/svm Changelog +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/svm/README.md b/typescript/packages/mechanisms/svm/README.md index 4014b3486f..a375722b8f 100644 --- a/typescript/packages/mechanisms/svm/README.md +++ b/typescript/packages/mechanisms/svm/README.md @@ -5,6 +5,7 @@ SVM (Solana Virtual Machine) implementation of the x402 payment protocol. **Payment schemes:** - **Exact** — fixed-price SPL Token transfers (client pays the advertised amount) - **Upto** — usage-based billing via [payment-channels](https://github.com/solana-foundation/payment-channels) (client authorizes a max; server settles actual usage) +- **Batch settlement** — long-lived payment channels with cumulative vouchers and periodic payouts ## Installation @@ -54,6 +55,32 @@ Usage-based payments: authorize a ceiling, settle actual usage. See [Upto SVM Sc | Server | `@x402/svm/upto/server` → `UptoSvmScheme` (requires `receiverAuthorizerSigner` unless the facilitator's `receiverAuthorizer` is delegated to) | | Facilitator | `@x402/svm/upto/facilitator` → `UptoSvmScheme` (requires `getSigner` on the facilitator signer) | +### Batch Settlement (`@x402/svm/batch-settlement/*`) + +Long-lived payment channels with cumulative vouchers (client-signed) or operator-metered server-signed mode. See [Batch-Settlement SVM Scheme](./src/batch-settlement/README.md). + +| Role | Import | +|------|--------| +| Client | `@x402/svm/batch-settlement/client` → `BatchSvmScheme` | +| Server | `@x402/svm/batch-settlement/server` → `BatchSvmScheme`, `BatchChannelManager` | +| Facilitator | `@x402/svm/batch-settlement/facilitator` → `BatchSvmScheme` | + +#### Recovery and payouts + +Distribution keeps its existing request: `type: "settle"` with `channelId` and `channelConfig` for each channel. It sweeps what is currently owed; a later retry may also pay newly claimed earnings. No payout idempotency key or request watermarks are required. + +The facilitator records signed bytes and their signature before submission, then reconciles or resends that same transaction after an uncertain outcome. Unresolved operations remain `settlement_pending`; a timeout or unavailable transaction history does not justify building a replacement. The one conclusive expiry signal is the transaction's own blockhash: when the signer reports it invalid (`isBlockhashValid`) and a history lookup (`getConfirmedTransaction`) still finds no record of the signature, the bytes can never land, so the operation is reported as `transaction_failed` and its queue released instead of staying pending. Confirmed account reads use the execution slot as `minContextSlot` when supplied by the signer; a read the backend rejects for that floor is retried before it fails the request. + +Distribution `amount` is the receiver's actual token credit in the identified transaction, including on recovery. Deduplicate accounting by network, transaction, asset and recipient rather than summing HTTP responses. The optional facilitator `onDistributionConfirmed` callback runs before recovery completion and may run more than once; it must be idempotent. Recording failures leave the original signature pending. + +After distribution, `BatchChannelManager` reads the confirmed channel payout watermark before marking local claims paid. An older recovered payout cannot mark newer earnings paid. Pass `rpcUrl` to `createChannelManager` for that read; `readPayoutWatermark` can replace it. Stale or unavailable results leave work for a later pass. Keep redemption outside the metered-request handler when payout latency should not delay responses. + +`InMemoryBatchPendingSettlementStore` is the default single-process reference store and does not survive restarts. Persistent stores must retain unresolved records and implement `setIfAbsent` and `deleteIfEquals` atomically across processes. Shared deployments must also serialize overlapping distribution batches; these primitives do not provide a distributed scheduler. Stores without atomic methods provide only local serialization. Completed-result retention is an operator policy; unresolved records must not silently expire. + +Custom signers should return the execution slot from `confirmTransaction`, honor `minContextSlot`, and implement `getConfirmedTransaction` with confirmed token-balance metadata. The default adapter supports these capabilities. Missing payout metadata leaves the original transaction pending. + +When a sealed channel's receiver is also the refund recipient or protocol treasury, aggregate token balances can combine earnings with other credits. That payout is answered with `invalid_batch_settlement_svm_payout_attribution_ambiguous` and the transaction signature instead of the combined amount, and the sweep queue is released so later sweeps of those channels are not blocked. The transaction has completed onchain: reconcile it by signature, and never repeat it as a replacement payment. The `onDistributionConfirmed` callback is not invoked for it because no amount can be attributed. + ### V1 Package (`@x402/svm/v1`) **V1 Protocol Support** - Legacy x402 protocol with simple network names diff --git a/typescript/packages/mechanisms/svm/eslint.config.js b/typescript/packages/mechanisms/svm/eslint.config.js index b539328a7d..b0a3f7d194 100644 --- a/typescript/packages/mechanisms/svm/eslint.config.js +++ b/typescript/packages/mechanisms/svm/eslint.config.js @@ -8,7 +8,7 @@ import sonarjs from "eslint-plugin-sonarjs"; export default [ { - ignores: ["dist/**", "node_modules/**"], + ignores: ["dist/**", "node_modules/**", "src/payment-channels/generated/**"], }, { files: ["**/*.ts", "**/*.tsx"], diff --git a/typescript/packages/mechanisms/svm/idl/payment-channels.json b/typescript/packages/mechanisms/svm/idl/payment-channels.json new file mode 100644 index 0000000000..eb6f704524 --- /dev/null +++ b/typescript/packages/mechanisms/svm/idl/payment-channels.json @@ -0,0 +1,1818 @@ +{ + "additionalPrograms": [], + "kind": "rootNode", + "program": { + "accounts": [ + { + "data": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "version", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "bump", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "status", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "salt", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "deposit", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "settlement", + "type": { + "kind": "definedTypeLinkNode", + "name": "settlementWatermarks" + } + }, + { + "kind": "structFieldTypeNode", + "name": "closureStartedAt", + "type": { + "endian": "le", + "format": "i64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "payerWithdrawnAt", + "type": { + "endian": "le", + "format": "i64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "gracePeriod", + "type": { + "endian": "le", + "format": "u32", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "distributionHash", + "type": { + "count": { + "kind": "fixedCountNode", + "value": 32 + }, + "item": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "kind": "arrayTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "payer", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "payee", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "authorizedSigner", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "mint", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "rentPayer", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "openSlot", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + }, + "kind": "accountNode", + "name": "channel" + } + ], + "definedTypes": [ + { + "kind": "definedTypeNode", + "name": "distributeArgs", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "recipients", + "type": { + "count": { + "kind": "prefixedCountNode", + "prefix": { + "endian": "le", + "format": "u32", + "kind": "numberTypeNode" + } + }, + "item": { + "kind": "definedTypeLinkNode", + "name": "distributionEntry" + }, + "kind": "arrayTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "payoutBeneficiary", + "type": { + "kind": "enumTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "variants": [ + { + "kind": "enumEmptyVariantTypeNode", + "name": "recipient" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "payee" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "payer" + } + ] + } + }, + { + "kind": "definedTypeNode", + "name": "redirectReason", + "type": { + "kind": "enumTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "variants": [ + { + "kind": "enumEmptyVariantTypeNode", + "name": "unsupportedExtension" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "closedOrMalformed" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "notInitialized" + }, + { + "kind": "enumEmptyVariantTypeNode", + "name": "reassignedAuthority" + } + ] + } + }, + { + "kind": "definedTypeNode", + "name": "distributionEntry", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "recipient", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "bps", + "type": { + "endian": "le", + "format": "u16", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "openArgs", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "salt", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "deposit", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "gracePeriod", + "type": { + "endian": "le", + "format": "u32", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "openSlot", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "recipients", + "type": { + "count": { + "kind": "prefixedCountNode", + "prefix": { + "endian": "le", + "format": "u32", + "kind": "numberTypeNode" + } + }, + "item": { + "kind": "definedTypeLinkNode", + "name": "distributionEntry" + }, + "kind": "arrayTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "settleAndSealArgs", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "hasVoucher", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "topUpArgs", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "amount", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "voucherArgs", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "magic", + "type": { + "count": { + "kind": "fixedCountNode", + "value": 2 + }, + "item": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "kind": "arrayTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "channelId", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "cumulativeAmount", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "expiresAt", + "type": { + "endian": "le", + "format": "i64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "channelStatus", + "type": { + "kind": "enumTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "variants": [ + { + "discriminator": 0, + "kind": "enumEmptyVariantTypeNode", + "name": "open" + }, + { + "discriminator": 1, + "kind": "enumEmptyVariantTypeNode", + "name": "sealed" + }, + { + "discriminator": 2, + "kind": "enumEmptyVariantTypeNode", + "name": "closing" + }, + { + "discriminator": 3, + "kind": "enumEmptyVariantTypeNode", + "name": "distributed" + } + ] + } + }, + { + "kind": "definedTypeNode", + "name": "settlementWatermarks", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "settled", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "payoutWatermark", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "accountDiscriminator", + "type": { + "kind": "enumTypeNode", + "size": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + }, + "variants": [ + { + "discriminator": 1, + "kind": "enumEmptyVariantTypeNode", + "name": "channel" + }, + { + "discriminator": 2, + "kind": "enumEmptyVariantTypeNode", + "name": "closedChannel" + } + ] + } + }, + { + "kind": "definedTypeNode", + "name": "opened", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "channel", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "openSlot", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + { + "kind": "definedTypeNode", + "name": "payoutRedirected", + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "channel", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "owner", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "amount", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "beneficiary", + "type": { + "kind": "definedTypeLinkNode", + "name": "payoutBeneficiary" + } + }, + { + "kind": "structFieldTypeNode", + "name": "reason", + "type": { + "kind": "definedTypeLinkNode", + "name": "redirectReason" + } + } + ], + "kind": "structTypeNode" + } + } + ], + "errors": [ + { + "code": 0, + "kind": "errorNode", + "message": "Not implemented", + "name": "notImplemented" + }, + { + "code": 1, + "kind": "errorNode", + "message": "A signature was required but not found", + "name": "missingRequiredSignature" + }, + { + "code": 2, + "kind": "errorNode", + "message": "Invalid channel status", + "name": "invalidChannelStatus" + }, + { + "code": 3, + "kind": "errorNode", + "message": "Invalid account discriminator", + "name": "invalidAccountDiscriminator" + }, + { + "code": 4, + "kind": "errorNode", + "message": "Unsupported channel version", + "name": "unsupportedChannelVersion" + }, + { + "code": 5, + "kind": "errorNode", + "message": "Account does not match channel payer", + "name": "invalidChannelPayer" + }, + { + "code": 6, + "kind": "errorNode", + "message": "Account does not match channel payee", + "name": "invalidChannelPayee" + }, + { + "code": 7, + "kind": "errorNode", + "message": "Account does not match channel mint", + "name": "invalidChannelMint" + }, + { + "code": 8, + "kind": "errorNode", + "message": "Invalid event authority", + "name": "invalidEventAuthority" + }, + { + "code": 9, + "kind": "errorNode", + "message": "Not enough accounts were provided", + "name": "notEnoughAccountKeys" + }, + { + "code": 10, + "kind": "errorNode", + "message": "Account does not match channel rent_payer", + "name": "invalidChannelRentPayer" + }, + { + "code": 50, + "kind": "errorNode", + "message": "Channel account does not match derived PDA", + "name": "channelAccountMismatch" + }, + { + "code": 51, + "kind": "errorNode", + "message": "Channel token account is not ATA(channel, mint, token_program)", + "name": "invalidChannelTokenAccount" + }, + { + "code": 52, + "kind": "errorNode", + "message": "Channel token account has invalid extensions", + "name": "invalidChannelTokenExtensions" + }, + { + "code": 53, + "kind": "errorNode", + "message": "Mint account does not match channel.mint", + "name": "mintAccountMismatch" + }, + { + "code": 54, + "kind": "errorNode", + "message": "Token program must be SPL Token or Token-2022", + "name": "invalidMintTokenProgram" + }, + { + "code": 55, + "kind": "errorNode", + "message": "Token account or mint TLV trailer is malformed", + "name": "malformedMintTokenAccountData" + }, + { + "code": 56, + "kind": "errorNode", + "message": "Token account or mint TLV trailer is malformed", + "name": "malformedMintTokenExtensions" + }, + { + "code": 57, + "kind": "errorNode", + "message": "Payer token account is not ATA(payer, token_program, mint)", + "name": "payerAccountMismatch" + }, + { + "code": 58, + "kind": "errorNode", + "message": "Payer token account is invalid", + "name": "invalidPayerTokenAccount" + }, + { + "code": 59, + "kind": "errorNode", + "message": "Payer token account has invalid extensions", + "name": "invalidPayerTokenExtensions" + }, + { + "code": 60, + "kind": "errorNode", + "message": "Payee token account is not ATA(payee, token_program, mint)", + "name": "payeeAccountMismatch" + }, + { + "code": 61, + "kind": "errorNode", + "message": "Payee token account is invalid", + "name": "invalidPayeeTokenAccount" + }, + { + "code": 62, + "kind": "errorNode", + "message": "Payee token account has invalid extensions", + "name": "invalidPayeeTokenExtensions" + }, + { + "code": 200, + "kind": "errorNode", + "message": "Deposit must be non-zero", + "name": "depositMustBeNonZero" + }, + { + "code": 201, + "kind": "errorNode", + "message": "Grace period must be non-zero", + "name": "gracePeriodMustBeNonZero" + }, + { + "code": 230, + "kind": "errorNode", + "message": "Missing Ed25519 precompile ix at current-1", + "name": "missingEd25519Verification" + }, + { + "code": 231, + "kind": "errorNode", + "message": "Malformed Ed25519 precompile instruction", + "name": "malformedEd25519Instruction" + }, + { + "code": 232, + "kind": "errorNode", + "message": "Voucher channel_id does not match channel PDA", + "name": "voucherChannelMismatch" + }, + { + "code": 233, + "kind": "errorNode", + "message": "Voucher expired", + "name": "voucherExpired" + }, + { + "code": 234, + "kind": "errorNode", + "message": "Voucher watermark not strictly monotonic", + "name": "voucherWatermarkNotMonotonic" + }, + { + "code": 235, + "kind": "errorNode", + "message": "Voucher cumulative_amount exceeds channel deposit", + "name": "voucherOverDeposit" + }, + { + "code": 236, + "kind": "errorNode", + "message": "Reserved (formerly: Ed25519 message does not match Borsh voucher payload)", + "name": "voucherMessageMismatch" + }, + { + "code": 237, + "kind": "errorNode", + "message": "Voucher signer does not match channel authorized_signer", + "name": "voucherSignerMismatch" + }, + { + "code": 238, + "kind": "errorNode", + "message": "Voucher payload magic prefix is invalid", + "name": "voucherBadMagic" + }, + { + "code": 260, + "kind": "errorNode", + "message": "num_recipients outside [0, 32]", + "name": "invalidRecipientCount" + }, + { + "code": 261, + "kind": "errorNode", + "message": "Each shareBps must be non-zero and Σbps must be at most 10_000", + "name": "invalidSplitConfig" + }, + { + "code": 262, + "kind": "errorNode", + "message": "num_recipients outside [0, 32]", + "name": "distributionPartsOverflow" + }, + { + "code": 263, + "kind": "errorNode", + "message": "Distribution plan contains a duplicate recipient address", + "name": "duplicateRecipient" + }, + { + "code": 264, + "kind": "errorNode", + "message": "num_recipients outside [0, 32]", + "name": "distributionAmountOverflow" + }, + { + "code": 265, + "kind": "errorNode", + "message": "Distribution preimage length calculation overflow", + "name": "distributionPreimageLengthOverflow" + }, + { + "code": 2000, + "kind": "errorNode", + "message": "Derived channel account address does not match the user provided address", + "name": "channelAddressMismatch" + }, + { + "code": 2001, + "kind": "errorNode", + "message": "Payer and payee must be different accounts", + "name": "payerPayeeMustDiffer" + }, + { + "code": 2002, + "kind": "errorNode", + "message": "authorized_signer must be a valid Ed25519 public key", + "name": "invalidAuthorizedSigner" + }, + { + "code": 2003, + "kind": "errorNode", + "message": "open_slot is in the future or older than the allowed slot window", + "name": "openSlotOutOfWindow" + }, + { + "code": 2100, + "kind": "errorNode", + "message": "Deposit must be non-zero", + "name": "topUpDepositOverflow" + }, + { + "code": 2200, + "kind": "errorNode", + "message": "Deadline overflow on grace period", + "name": "sealDeadlineOverflow" + }, + { + "code": 2201, + "kind": "errorNode", + "message": "Grace period has not elapsed yet", + "name": "sealGracePeriodNotElapsed" + }, + { + "code": 2300, + "kind": "errorNode", + "message": "Payer refund has already been claimed", + "name": "payerAlreadyWithdrawn" + }, + { + "code": 2301, + "kind": "errorNode", + "message": "Payer refund amount calculation underflow", + "name": "refundCalculationOverflow" + }, + { + "code": 2400, + "kind": "errorNode", + "message": "Channel is not in OPEN or SEALED", + "name": "channelNotDistributable" + }, + { + "code": 2401, + "kind": "errorNode", + "message": "Treasury token account is not ATA(TREASURY_OWNER, mint, token_program)", + "name": "treasuryAccountMismatch" + }, + { + "code": 2402, + "kind": "errorNode", + "message": "Treasury token account is invalid", + "name": "invalidTreasuryTokenAccount" + }, + { + "code": 2403, + "kind": "errorNode", + "message": "Treasury token account has invalid extensions", + "name": "invalidTreasuryTokenExtensions" + }, + { + "code": 2404, + "kind": "errorNode", + "message": "Recipient token account is not ATA(recipient, token_program, mint)", + "name": "recipientAccountMismatch" + }, + { + "code": 2405, + "kind": "errorNode", + "message": "Recipient token account is invalid", + "name": "invalidRecipientTokenAccount" + }, + { + "code": 2406, + "kind": "errorNode", + "message": "Recipient token account has invalid extensions", + "name": "invalidRecipientTokenExtensions" + }, + { + "code": 2407, + "kind": "errorNode", + "message": "Distribution hash mismatch", + "name": "invalidDistributionHash" + }, + { + "code": 2408, + "kind": "errorNode", + "message": "No newly settled funds to distribute", + "name": "nothingToDistribute" + }, + { + "code": 2409, + "kind": "errorNode", + "message": "Recipient ATA tail length does not match the committed plan's entry count", + "name": "recipientAccountCountMismatch" + }, + { + "code": 2410, + "kind": "errorNode", + "message": "Distribution pool calculation underflow", + "name": "distributePoolOverflow" + }, + { + "code": 2411, + "kind": "errorNode", + "message": "Channel rent rebalance calculation underflow", + "name": "distributeBalanceCalculationOverflow" + }, + { + "code": 2412, + "kind": "errorNode", + "message": "Rent payer lamports overflow on channel deallocation", + "name": "rentPayerBalanceOverflow" + }, + { + "code": 2413, + "kind": "errorNode", + "message": "Transfer queue capacity exceeded", + "name": "distributeTransferQueueOverflow" + }, + { + "code": 2414, + "kind": "errorNode", + "message": "Channel cannot be fully closed until clock.slot > open_slot + OPEN_SLOT_WINDOW", + "name": "channelCloseTooEarly" + } + ], + "events": [ + { + "data": { + "kind": "hiddenPrefixTypeNode", + "prefix": [ + { + "kind": "constantValueNode", + "type": { + "kind": "fixedSizeTypeNode", + "size": 8, + "type": { + "kind": "bytesTypeNode" + } + }, + "value": { + "data": "a6ac61094d4cbd6d", + "encoding": "base16", + "kind": "bytesValueNode" + } + } + ], + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "channel", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "openSlot", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + } + ], + "kind": "structTypeNode" + } + }, + "discriminators": [ + { + "constant": { + "kind": "constantValueNode", + "type": { + "kind": "fixedSizeTypeNode", + "size": 8, + "type": { + "kind": "bytesTypeNode" + } + }, + "value": { + "data": "a6ac61094d4cbd6d", + "encoding": "base16", + "kind": "bytesValueNode" + } + }, + "kind": "constantDiscriminatorNode", + "offset": 0 + } + ], + "kind": "eventNode", + "name": "opened" + }, + { + "data": { + "kind": "hiddenPrefixTypeNode", + "prefix": [ + { + "kind": "constantValueNode", + "type": { + "kind": "fixedSizeTypeNode", + "size": 8, + "type": { + "kind": "bytesTypeNode" + } + }, + "value": { + "data": "d116b9d754a75450", + "encoding": "base16", + "kind": "bytesValueNode" + } + } + ], + "type": { + "fields": [ + { + "kind": "structFieldTypeNode", + "name": "channel", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "owner", + "type": { + "kind": "publicKeyTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "amount", + "type": { + "endian": "le", + "format": "u64", + "kind": "numberTypeNode" + } + }, + { + "kind": "structFieldTypeNode", + "name": "beneficiary", + "type": { + "kind": "definedTypeLinkNode", + "name": "payoutBeneficiary" + } + }, + { + "kind": "structFieldTypeNode", + "name": "reason", + "type": { + "kind": "definedTypeLinkNode", + "name": "redirectReason" + } + } + ], + "kind": "structTypeNode" + } + }, + "discriminators": [ + { + "constant": { + "kind": "constantValueNode", + "type": { + "kind": "fixedSizeTypeNode", + "size": 8, + "type": { + "kind": "bytesTypeNode" + } + }, + "value": { + "data": "d116b9d754a75450", + "encoding": "base16", + "kind": "bytesValueNode" + } + }, + "kind": "constantDiscriminatorNode", + "offset": 0 + } + ], + "kind": "eventNode", + "name": "payoutRedirected" + } + ], + "instructions": [ + { + "accounts": [ + { + "isSigner": true, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "isSigner": true, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "rentPayer" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "payee" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "mint" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "authorizedSigner" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payerTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channelTokenAccount" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenProgram" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "11111111111111111111111111111111" + }, + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "systemProgram" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "rent" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "associatedTokenProgram" + }, + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "eventAuthority" + }, + "seeds": [] + }, + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + }, + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "selfProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 1 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "openArgs", + "type": { + "kind": "definedTypeLinkNode", + "name": "openArgs" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "open" + }, + { + "accounts": [ + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "instructionsSysvar" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 2 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "settle" + }, + { + "accounts": [ + { + "isSigner": true, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payerTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channelTokenAccount" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "mint" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 3 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "topUpArgs", + "type": { + "kind": "definedTypeLinkNode", + "name": "topUpArgs" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "topUp" + }, + { + "accounts": [ + { + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "payee" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "instructionsSysvar" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 4 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "settleAndSealArgs", + "type": { + "kind": "definedTypeLinkNode", + "name": "settleAndSealArgs" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "settleAndSeal" + }, + { + "accounts": [ + { + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 5 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "requestClose" + }, + { + "accounts": [ + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 6 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "seal" + }, + { + "accounts": [ + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "rentPayer" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channelTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payerTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payeeTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "treasuryTokenAccount" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "mint" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenProgram" + }, + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "eventAuthority" + }, + "seeds": [] + }, + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + }, + { + "defaultValue": { + "kind": "publicKeyValueNode", + "publicKey": "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" + }, + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "selfProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 7 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + }, + { + "kind": "instructionArgumentNode", + "name": "distributeArgs", + "type": { + "kind": "definedTypeLinkNode", + "name": "distributeArgs" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "distribute", + "remainingAccounts": [ + { + "isSigner": false, + "isWritable": true, + "kind": "instructionRemainingAccountsNode", + "value": { + "kind": "argumentValueNode", + "name": "recipientTokenAccounts" + } + } + ] + }, + { + "accounts": [ + { + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "payer" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channelTokenAccount" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "payerTokenAccount" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "mint" + }, + { + "isSigner": false, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "tokenProgram" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 8 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "withdrawPayer" + }, + { + "accounts": [ + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "channel" + }, + { + "isSigner": false, + "isWritable": true, + "kind": "instructionAccountNode", + "name": "rentPayer" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 9 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "reclaim" + }, + { + "accounts": [ + { + "defaultValue": { + "kind": "pdaValueNode", + "pda": { + "kind": "pdaLinkNode", + "name": "eventAuthority" + }, + "seeds": [] + }, + "isSigner": true, + "isWritable": false, + "kind": "instructionAccountNode", + "name": "eventAuthority" + } + ], + "arguments": [ + { + "defaultValue": { + "kind": "numberValueNode", + "number": 228 + }, + "defaultValueStrategy": "omitted", + "kind": "instructionArgumentNode", + "name": "discriminator", + "type": { + "endian": "le", + "format": "u8", + "kind": "numberTypeNode" + } + } + ], + "discriminators": [ + { + "kind": "fieldDiscriminatorNode", + "name": "discriminator", + "offset": 0 + } + ], + "kind": "instructionNode", + "name": "emitEvent" + } + ], + "kind": "programNode", + "name": "paymentChannels", + "pdas": [ + { + "kind": "pdaNode", + "name": "eventAuthority", + "seeds": [ + { + "kind": "constantPdaSeedNode", + "type": { + "encoding": "utf8", + "kind": "stringTypeNode" + }, + "value": { + "kind": "stringValueNode", + "string": "event_authority" + } + } + ] + } + ], + "publicKey": "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", + "version": "0.1.0" + }, + "standard": "codama", + "version": "1.6.0" +} diff --git a/typescript/packages/mechanisms/svm/idl/payment-channels.source.json b/typescript/packages/mechanisms/svm/idl/payment-channels.source.json new file mode 100644 index 0000000000..de76f82bf0 --- /dev/null +++ b/typescript/packages/mechanisms/svm/idl/payment-channels.source.json @@ -0,0 +1,6 @@ +{ + "repository": "solana-foundation/payment-channels", + "revision": "0c07d5751c8972abf6a219570a3f39a72f46f879", + "path": "program/payment_channels/idl/payment_channels.json", + "gitBlob": "eb6f704524dfdb044753f29831ad55120fcabc22" +} diff --git a/typescript/packages/mechanisms/svm/package.json b/typescript/packages/mechanisms/svm/package.json index 64b8958129..00425068a9 100644 --- a/typescript/packages/mechanisms/svm/package.json +++ b/typescript/packages/mechanisms/svm/package.json @@ -1,12 +1,12 @@ { "name": "@x402/svm", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { "start": "tsx --env-file=.env index.ts", - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", @@ -14,7 +14,11 @@ "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", "lint": "eslint . --ext .ts --fix", - "lint:check": "eslint . --ext .ts" + "lint:check": "eslint . --ext .ts", + "payment-channels:pull-idl": "pnpm --dir ../../../../scripts/payment-channels-codegen pull-idl", + "payment-channels:generate": "pnpm --dir ../../../../scripts/payment-channels-codegen generate", + "payment-channels:sync": "pnpm payment-channels:pull-idl && pnpm payment-channels:generate", + "payment-channels:check": "pnpm payment-channels:generate && git diff --exit-code -- src/payment-channels/generated" }, "keywords": [ "x402", @@ -52,6 +56,7 @@ "@solana-program/compute-budget": "^0.11.0", "@solana-program/token": "^0.9.0", "@solana-program/token-2022": "^0.6.1", + "@solana/program-client-core": "^6.1.0", "@x402/core": "workspace:~" }, "peerDependencies": { @@ -157,6 +162,36 @@ "types": "./dist/cjs/upto/facilitator/index.d.ts", "default": "./dist/cjs/upto/facilitator/index.js" } + }, + "./batch-settlement/client": { + "import": { + "types": "./dist/esm/batch-settlement/client/index.d.mts", + "default": "./dist/esm/batch-settlement/client/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/client/index.d.ts", + "default": "./dist/cjs/batch-settlement/client/index.js" + } + }, + "./batch-settlement/server": { + "import": { + "types": "./dist/esm/batch-settlement/server/index.d.mts", + "default": "./dist/esm/batch-settlement/server/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/server/index.d.ts", + "default": "./dist/cjs/batch-settlement/server/index.js" + } + }, + "./batch-settlement/facilitator": { + "import": { + "types": "./dist/esm/batch-settlement/facilitator/index.d.mts", + "default": "./dist/esm/batch-settlement/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/facilitator/index.d.ts", + "default": "./dist/cjs/batch-settlement/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/README.md b/typescript/packages/mechanisms/svm/src/batch-settlement/README.md new file mode 100644 index 0000000000..ec47fc99b4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/README.md @@ -0,0 +1,285 @@ +# Batch-Settlement SVM Scheme (`@x402/svm/batch-settlement`) + +The **batch-settlement** scheme enables high-throughput payments on Solana. The client deposits once into a [payment-channels](https://github.com/solana-foundation/payment-channels) escrow, then pays for many requests without an onchain transaction per call. The resource server verifies each authorization offchain, serves immediately, and redeems accumulated vouchers later through the facilitator. + +Two voucher modes share the same channel lifecycle: + +| Mode | Who signs vouchers | Per-request wire payload | Typical use | +|------|-------------------|--------------------------|-------------| +| **Client-signed** (default) | Client (`payerAuthorizer`) | Cumulative Ed25519 voucher | Fixed price per request; client retains full voucher authority | +| **Server-signed** | Resource `operator` | Expiring payer proof + request id; operator signs the metered cumulative charge after serving | Usage-based billing without a client signature on every request | + +In server mode the channel's onchain `authorized_signer` is the operator, who could claim the full unspent deposit. Clients enter that mode only for operator keys listed in `serverSignedChannelsPolicy`, with escrow capped by `maxDeposit` (default `$1` for default assets). An untrusted server-signed accept is refused; when the same route also offers client-signed, the scheme falls back automatically. + +Uses the same payment-channels program as SVM `upto`. `upto` is a one-request channel that closes after one metered charge; **batch-settlement** keeps the channel open, advances a cumulative watermark across requests, and batches redemption. + +## Import Paths + +| Role | Import | +|------|--------| +| Client | `@x402/svm/batch-settlement/client` | +| Server | `@x402/svm/batch-settlement/server` | +| Facilitator | `@x402/svm/batch-settlement/facilitator` | + +## Client Usage + +Register `BatchSvmScheme` with an `x402Client`. The SDK opens or tops up the channel on first use, tracks cumulative spend locally, and attaches the correct payload (`voucher` or `authorization`) per mode. + +```typescript +import { x402Client } from "@x402/core/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { BatchSvmScheme } from "@x402/svm/batch-settlement/client"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +const signer = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_PRIVATE_KEY!), +); + +const client = new x402Client(); +client.register("solana:*", new ExactSvmScheme(signer)); // fixed-price services +client.register("solana:*", new BatchSvmScheme(signer, { + depositPolicy: { depositMultiplier: 5 }, + rpcUrl: process.env.SVM_RPC_URL, +})); +``` + +### Deposit sizing + +When a channel needs funding or top-up, the client targets: + +1. `extra.minDeposit` when the server announced a valid hint (`>=` per-request `amount`) +2. otherwise `amount × depositMultiplier` (default 5, minimum 3) + +`x402Client` spend controls still cap each request's `PaymentRequirements.amount`. The same resolved cap scales the escrow ceiling when a spend cap is set. + +### Client-signed channels + +No extra client configuration is required. Each paid request carries a cumulative voucher signed by the payer (or a dedicated voucher key equal to `payerAuthorizer`). The per-request charge equals `PaymentRequirements.amount` — there is no metered override on this accept. + +### Server-signed channels (trusted operator) + +Opt in explicitly. List operators you trust out of band and cap escrow per channel: + +```typescript +const batch = new BatchSvmScheme(signer, { + serverSignedChannelsPolicy: { + allowedOperators: ["OperatorBase58Pubkey"], + maxDeposit: "$1", // USD cap for default assets; use `allowedAssets` for other mints + }, +}); + +client.register("solana:*", batch); +// Optional: prefer a trusted operator's metered accept over a fixed-price accept on the same route +client.registerPolicy(batch.paymentPolicy); +``` + +If a 402 advertises `extra.voucherSigner: "server"` for an operator not in `allowedOperators`, payment creation fails with `UntrustedOperatorError` and the scheme retries the same resource's `extra.voucherSigner: "client"` accept when offered. + +### Cooperative refund + +Return unused escrow without waiting for `withdrawDelay` when the server and facilitator cooperate: + +```typescript +const settle = await batch.refund("https://api.example.com/protected-route"); +// Partial: await batch.refund(url, { amount: "1000000" }); +``` + +### Persistence + +Channel allocations default to in-memory. Implement `BatchClientChannelStorage` or keep `channelStorage` durable across restarts so the client does not reopen channels or lose pending state. + +## Server Usage + +Register `BatchSvmScheme` with an `x402ResourceServer`. The server owns the offchain voucher watermark: verify reserves capacity, the handler runs, settle commits the charge and stores the latest voucher for redemption. + +### Receiver authorizer + +Every channel is bound at `open` to `extra.receiverAuthorizer` via an onchain Memo. That key signs cooperative `CloseAuthorization` messages for refunds and for sealing channels the payer is closing. + +- **Self-managed** (recommended): pass `receiverAuthorizer` in `BatchSvmServerConfig`. Advertised as `extra.receiverAuthorizer`; must match the binding Memo the client includes at open. +- **Facilitator-delegated**: omit `receiverAuthorizer`. Copy `extra.receiverAuthorizer` from the facilitator's `/supported` advertisement. Cooperative closes authenticate the caller identity at the facilitator instead of a local `CloseAuthorization`. + +```typescript +import { BatchSvmScheme, MemoryChannelStore } from "@x402/svm/batch-settlement/server"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { base58 } from "@scure/base"; + +const receiverAuthorizer = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_RECEIVER_AUTHORIZER_PRIVATE_KEY!), +); + +new BatchSvmScheme({ + receiverAuthorizer, + withdrawDelay: 86_400, + store: new MemoryChannelStore(), +}); +``` + +### Client-signed routes + +Omit `operator`. Routes advertise batch-settlement without `extra.voucherSigner` (client mode). Charge is fixed to the route `price` / `amount`. + +### Server-signed routes (metered) + +Configure an `operator` signer. Once set, batch routes default to server mode and advertise `extra.operator` and `extra.voucherSigner: "server"`. Pin a route back to client mode with `extra.voucherSigner: "client"` so untrusted clients can still pay the ceiling with their own vouchers. + +```typescript +import { paymentMiddleware, setSettlementOverrides, x402ResourceServer } from "@x402/express"; +import { BatchSvmScheme } from "@x402/svm/batch-settlement/server"; + +const operator = await createKeyPairSignerFromBytes( + base58.decode(process.env.SVM_OPERATOR_PRIVATE_KEY!), +); + +const server = new x402ResourceServer(facilitatorClient).register( + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + new BatchSvmScheme({ + receiverAuthorizer, + operator, + store: new MemoryChannelStore(), + }), +); + +const routes = { + "GET /api/generate": { + accepts: [ + { + scheme: "batch-settlement", + price: "$0.10", // ceiling per request in server mode + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: "YourSolanaAddress", + }, + { + scheme: "batch-settlement", + price: "$0.10", + network: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + payTo: "YourSolanaAddress", + extra: { voucherSigner: "client" }, + }, + ], + description: "Metered API with a client-signed fallback", + }, +}; + +// Server-signed only: bill actual usage after the handler runs +app.get("/api/generate", (req, res) => { + setSettlementOverrides(res, { amount: "50%" }); // or raw atomic units / dollar price + res.json({ result: "..." }); +}); +``` + +### Settlement override formats (server-signed) + +The `amount` in `setSettlementOverrides` supports the same formats as upto: + +| Format | Example | Description | +|--------|---------|-------------| +| Raw atomic units | `"50000"` | Cumulative increment for this request | +| Percentage | `"50%"` | Fraction of the authorized ceiling | +| Dollar price | `"$0.05"` | Converts to atomic units when the route used `$` pricing | + +### Redemption worker + +Vouchers are worthless until claimed onchain. Start `BatchChannelManager` outside the request path to claim batches and distribute payouts: + +```typescript +const supported = await facilitatorClient.getSupported(); +const kind = supported.kinds.find( + k => k.scheme === "batch-settlement" && k.network === SVM_NETWORK, +); +const requirements = await batchedSvmScheme.enhancePaymentRequirements( + { scheme: "batch-settlement", network: SVM_NETWORK, amount: "10000", asset: "...", payTo, maxTimeoutSeconds: 300, extra: {} }, + kind!, + [], +); + +const manager = batchedSvmScheme.createChannelManager(facilitatorClient, requirements, { + rpcUrl: process.env.SVM_RPC_URL, + onClaim: r => console.log(`Claimed ${r.vouchers} vouchers (tx: ${r.transaction})`), + onSettle: r => console.log(`Distributed to payTo (tx: ${r.transaction})`), + onSeal: r => console.log(`Sealed channel ${r.channel} (tx: ${r.transaction})`), + onError: e => console.error(e), +}); +manager.start(60); // seconds between passes +``` + +Claim promptly: if the facilitator or payer forces a close before your latest voucher is claimed, unclaimed value returns to the payer. + +## Facilitator Usage + +For custom facilitator implementations: + +```typescript +import { toFacilitatorSvmSigner } from "@x402/svm"; +import { + BatchSvmScheme, + InMemoryBatchChannelStorage, + InMemoryBatchDelegatedAuthStore, + InMemoryBatchReceiverAuthorizerStore, +} from "@x402/svm/batch-settlement/facilitator"; + +const svmSigner = toFacilitatorSvmSigner( + keypair, + process.env.SVM_RPC_URL ? { defaultRpcUrl: process.env.SVM_RPC_URL } : undefined, +); + +const scheme = new BatchSvmScheme(svmSigner, { + channelStorage: new InMemoryBatchChannelStorage(), + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + // Optional delegated closes. `receiverAuthorizer` is a Solana address you + // choose, separate from the fee-payer signers. This facilitator does not + // sign with it: a channel bound to that address authenticates seal and + // cooperative refund by caller identity instead of CloseAuthorization. + // delegatedReceiverAuth: { + // receiverAuthorizer, + // identityStore: new InMemoryBatchDelegatedAuthStore(), + // resolveCallerIdentity: () => callerIdentity, + // }, +}); + +const rentCleanup = scheme.createRentCleanupManager(network); +rentCleanup.start({ + intervalSecs: 60, + abandonGraceSecs: 120, +}); +``` + +Configure `receiverAuthorizerStore`, `receiverBindingHistoryReader`, or both. The history reader is used only when set here; it is not taken from the signer. A store-only facilitator binds the receiver authorizer and reads it back before broadcasting an open, and does not broadcast if that write fails. When both are set, a failed store write still broadcasts the open. The binding is checked on cooperative `seal` and `refund` only. + +`getExtra()` advertises `feePayer` (channel `rent_payer` and zero-share `payee`) and, when idle cleanup is enabled, `maxIdleSecs`. It advertises `receiverAuthorizer` only when `delegatedReceiverAuth` is set. `withdrawDelay` comes from the server. Production deployments should use durable `pendingSettlementStore`, a shared `channelStorage`, and a shared `identityStore` when running multiple replicas. + +## Supported Networks + +Works on Solana networks where the payment-channels program is deployed: + +| Network | CAIP-2 ID | +|---------|-----------| +| Solana Mainnet Beta | `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` | +| Solana Devnet | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | + +Canonical program id: `CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX` + +## How It Works + +1. Server advertises `scheme: "batch-settlement"` with per-request `price`, plus `extra.receiverAuthorizer`, `extra.withdrawDelay`, and (in server mode) `extra.operator` / `extra.voucherSigner: "server"` +2. Facilitator advertises `extra.feePayer` and optional `extra.maxIdleSecs` +3. Client signs `open` (or top-up) with the receiver-binding Memo; facilitator broadcasts deposit (escrow settle, before handler) +4. Each request: client mode sends a cumulative voucher; server mode sends a payer authorization and the operator signs the metered cumulative voucher after serving +5. Server verifies offchain, serves, and accumulates the latest voucher locally +6. `BatchChannelManager` submits `claim` (batched `settle`) and `settle` (`distribute`) through the facilitator +7. Cooperative `refund` or payer `request_close` returns unused escrow per the spec; rent cleanup reclaims abandoned channel PDAs + +## Examples + +- [Express batch-settlement server](https://github.com/x402-foundation/x402/tree/main/examples/typescript/servers/batch-settlement) (EVM + SVM, dual accepts when an operator is configured) +- [Batch-settlement client](https://github.com/x402-foundation/x402/tree/main/examples/typescript/clients/batch-settlement) (`SVM_SERVER_SIGNED_OPERATORS` for trusted operators) +- [Batch-settlement facilitator](https://github.com/x402-foundation/x402/tree/main/examples/typescript/facilitator/batch-settlement) + +## See Also + +- [Exact SVM Scheme](../../README.md) — fixed-price SPL transfers +- [Upto SVM Scheme](../upto/README.md) — single-request metered channels +- [Batch-Settlement EVM Scheme](../../../evm/src/batch-settlement/README.md) — EVM counterpart +- [SVM `batch-settlement` Scheme Spec](../../../../../../specs/schemes/batch-settlement/scheme_batch_settlement_svm.md) +- [x402 Docs: Payment Schemes](https://docs.x402.org/getting-started/quickstart-for-sellers#payment-schemes-exact-vs-upto) diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/authorization.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/authorization.ts new file mode 100644 index 0000000000..876c20d0f2 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/authorization.ts @@ -0,0 +1,144 @@ +/** Expiring payer authorization for server-signed batch-settlement channels. */ + +import { + createSignableMessage, + getBase58Decoder, + getBase58Encoder, + type MessagePartialSigner, +} from "@solana/kit"; + +import { verifyEd25519Signature } from "../payment-channels/voucher"; +import { AUTHORIZATION_DOMAIN } from "./constants"; +import type { BatchAuthorization } from "./types"; + +/** + * Encode the channel-bound proof that lets an operator meter requests. + * + * @param args - Authorization identities + * @param args.channelId - Channel PDA + * @param args.payer - Channel payer + * @param args.operator - Delegated voucher signer + * @param args.requestId - Single-use request identifier + * @param args.authorizedAmount - Maximum charge in atomic units + * @param args.expiresAt - Unix timestamp after which the proof is invalid + * @returns Bytes the payer signs + */ +export function encodeBatchAuthorizationMessage(args: { + channelId: string; + payer: string; + operator: string; + requestId: string; + authorizedAmount: bigint; + expiresAt: number; +}): Uint8Array { + const base58 = getBase58Encoder(); + const channel = base58.encode(args.channelId); + const payer = base58.encode(args.payer); + const operator = base58.encode(args.operator); + if (channel.byteLength !== 32 || payer.byteLength !== 32 || operator.byteLength !== 32) { + throw new Error("batch authorization keys must decode to 32 bytes"); + } + if (!Number.isSafeInteger(args.expiresAt) || args.expiresAt <= 0) { + throw new Error("batch authorization expiresAt must be a positive safe integer"); + } + const requestId = new TextEncoder().encode(args.requestId); + if (requestId.byteLength === 0 || requestId.byteLength > 256) { + throw new Error("batch authorization requestId must encode to 1 through 256 bytes"); + } + if (args.authorizedAmount < 0n || args.authorizedAmount > 0xffff_ffff_ffff_ffffn) { + throw new Error("batch authorization authorizedAmount must fit in a u64"); + } + const message = new Uint8Array(AUTHORIZATION_DOMAIN.byteLength + 114 + requestId.byteLength); + message.set(AUTHORIZATION_DOMAIN, 0); + message.set(channel, AUTHORIZATION_DOMAIN.byteLength); + message.set(payer, AUTHORIZATION_DOMAIN.byteLength + 32); + message.set(operator, AUTHORIZATION_DOMAIN.byteLength + 64); + const data = new DataView(message.buffer); + data.setUint16(AUTHORIZATION_DOMAIN.byteLength + 96, requestId.byteLength, true); + message.set(requestId, AUTHORIZATION_DOMAIN.byteLength + 98); + data.setBigUint64( + AUTHORIZATION_DOMAIN.byteLength + 98 + requestId.byteLength, + args.authorizedAmount, + true, + ); + data.setBigInt64( + AUTHORIZATION_DOMAIN.byteLength + 106 + requestId.byteLength, + BigInt(args.expiresAt), + true, + ); + return message; +} + +/** + * Sign an expiring authorization for one operator-bound channel. + * + * @param payer - Channel payer and proof signer + * @param channelId - Channel PDA + * @param operator - Delegated voucher signer + * @param requestId - Single-use request identifier + * @param authorizedAmount - Maximum charge in atomic units + * @param expiresAt - Unix timestamp after which the proof is invalid + * @returns Expiring bearer proof + */ +export async function signBatchAuthorization( + payer: MessagePartialSigner, + channelId: string, + operator: string, + requestId: string, + authorizedAmount: bigint, + expiresAt: number, +): Promise { + const message = encodeBatchAuthorizationMessage({ + channelId, + operator, + payer: payer.address, + requestId, + authorizedAmount, + expiresAt, + }); + const [signatures] = await payer.signMessages([createSignableMessage(message)]); + const signature = signatures[payer.address]; + if (!signature) throw new Error("payer did not return a batch authorization signature"); + return { + type: "proof", + channelId, + payer: payer.address, + requestId, + authorizedAmount: authorizedAmount.toString(), + expiresAt, + signature: getBase58Decoder().decode(signature as Uint8Array), + }; +} + +/** + * Verify an unexpired server-mode authorization against the channel payer. + * + * @param authorization - Payer's bearer proof + * @param operator - Operator the proof must bind + * @param nowSeconds - Current Unix time used for expiry validation + * @returns Whether the payer signature is valid + */ +export async function verifyBatchAuthorization( + authorization: BatchAuthorization, + operator: string, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + try { + if (authorization.expiresAt <= nowSeconds) return false; + const base58 = getBase58Encoder(); + return await verifyEd25519Signature({ + message: encodeBatchAuthorizationMessage({ + channelId: authorization.channelId, + operator, + payer: authorization.payer, + requestId: authorization.requestId, + authorizedAmount: BigInt(authorization.authorizedAmount), + expiresAt: authorization.expiresAt, + }), + publicKey: base58.encode(authorization.payer) as Uint8Array, + signature: base58.encode(authorization.signature) as Uint8Array, + }); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/channel.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/channel.ts new file mode 100644 index 0000000000..17c2f515d8 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/channel.ts @@ -0,0 +1,332 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/** Client-side payment-channel construction for SVM `batch-settlement`. */ + +import { + createSignableMessage, + getBase58Decoder, + type MessagePartialSigner, + type TransactionSigner, +} from "@solana/kit"; + +import { buildRequestCloseTransaction } from "../../payment-channels/close"; +import { buildOpenPaymentChannelTransaction } from "../../payment-channels/open"; +import { encodeVoucherMessageBytes } from "../../payment-channels/voucher"; +import { signBatchAuthorization } from "../authorization"; +import { CLIENT_VOUCHER_EXPIRES_AT, FULL_SPLIT_BPS } from "../constants"; +import { encodeReceiverBindingMemo } from "../receiverBinding"; +import type { + BatchAuthorization, + BatchChannelConfig, + BatchDepositPayload, + BatchRefundPayload, + BatchVoucher, +} from "../types"; + +export type BatchClientSigner = TransactionSigner & MessagePartialSigner; + +export async function signBatchVoucher( + signer: BatchClientSigner, + voucher: { channelId: string; maxClaimableAmount: bigint; expiresAt: number }, +): Promise { + const message = encodeVoucherMessageBytes({ + channelId: voucher.channelId, + cumulativeAmount: voucher.maxClaimableAmount, + expiresAt: BigInt(voucher.expiresAt), + }); + const [signatures] = await signer.signMessages([createSignableMessage(message)]); + const signature = signatures[signer.address]; + if (!signature) throw new Error("payer authorizer did not return a voucher signature"); + return { + channelId: voucher.channelId, + expiresAt: voucher.expiresAt, + maxClaimableAmount: voucher.maxClaimableAmount.toString(), + signature: getBase58Decoder().decode(signature as Uint8Array), + }; +} + +export class BatchChannelTracker { + private chargedCumulativeAmount: bigint; + + constructor( + readonly channelId: string, + readonly channelConfig: BatchChannelConfig, + private readonly signer: BatchClientSigner, + initialCumulative = 0n, + ) { + this.chargedCumulativeAmount = initialCumulative; + } + + get cumulative(): bigint { + return this.chargedCumulativeAmount; + } + + /** + * Create, but do not commit, the next voucher. + * + * @param charge - The amount to add to the confirmed cumulative allocation. + * @returns A signed voucher for the proposed cumulative allocation. + */ + async previewVoucher(charge: bigint): Promise { + if (charge <= 0n) throw new Error("charge must be positive"); + if (this.channelConfig.voucherSigner === "server") { + throw new Error("server-signed channels do not use client vouchers"); + } + return signBatchVoucher(this.signer, { + channelId: this.channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + maxClaimableAmount: this.chargedCumulativeAmount + charge, + }); + } + + /** + * Create an expiring payer proof for a server-signed channel. + * + * @param requestId - Single-use request identifier + * @param authorizedAmount - Maximum charge in atomic units + * @param expiresAt - Unix timestamp after which the proof is invalid + * @returns Expiring bearer proof + */ + async authorization( + requestId: string, + authorizedAmount: bigint, + expiresAt: number, + ): Promise { + if (this.channelConfig.voucherSigner !== "server") { + throw new Error("client-signed channels do not use server authorization"); + } + return signBatchAuthorization( + this.signer, + this.channelId, + this.channelConfig.payerAuthorizer, + requestId, + authorizedAmount, + expiresAt, + ); + } + + /** + * Commit a cumulative amount confirmed by the resource server. + * + * @param cumulative - The confirmed cumulative allocation. + */ + commit(cumulative: bigint): void { + if (cumulative < this.chargedCumulativeAmount) { + throw new Error("confirmed cumulative amount cannot move backwards"); + } + this.chargedCumulativeAmount = cumulative; + } + + async voucher(charge: bigint): Promise { + const voucher = await this.previewVoucher(charge); + this.commit(this.chargedCumulativeAmount + charge); + return voucher; + } + + /** + * Client-mode refund voucher at the confirmed cumulative allocation. + * + * @returns A voucher signed for the charged cumulative amount + */ + async refundVoucher(): Promise { + if (this.channelConfig.voucherSigner === "server") { + throw new Error("server-signed channels refund with payer authorization"); + } + return signBatchVoucher(this.signer, { + channelId: this.channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + maxClaimableAmount: this.chargedCumulativeAmount, + }); + } +} + +// Client voucher, or the payer authorization a server-signed charge needs. +export async function credentialFor( + mode: "client", + tracker: BatchChannelTracker, + charge: bigint, + authorization?: undefined, + refund?: boolean, +): Promise<{ voucher: BatchVoucher }>; +export async function credentialFor( + mode: "server", + tracker: BatchChannelTracker, + charge: bigint, + authorization: { requestId: string; expiresAt: number }, + refund?: boolean, +): Promise<{ authorization: BatchAuthorization }>; +export async function credentialFor( + mode: "client" | "server", + tracker: BatchChannelTracker, + charge: bigint, + authorization?: { requestId: string; expiresAt: number }, + refund = false, +): Promise<{ voucher: BatchVoucher } | { authorization: BatchAuthorization }> { + switch (mode) { + case "client": + return { + voucher: refund ? await tracker.refundVoucher() : await tracker.previewVoucher(charge), + }; + case "server": { + if (!authorization) { + throw new Error("authorizationExpiresAt is required for operator voucher signing"); + } + return { + authorization: await tracker.authorization( + authorization.requestId, + charge, + authorization.expiresAt, + ), + }; + } + default: { + const unexpected: never = mode; + throw new Error(String(unexpected)); + } + } +} + +export interface BuildDepositArgs { + payer: BatchClientSigner; + receiver: string; + receiverAuthorizer: string; + mint: string; + feePayer: string; + tokenProgram: string; + blockhash: { blockhash: string; lastValidBlockHeight: bigint }; + openSlot: bigint; + depositAmount: bigint; + firstCharge: bigint; + withdrawDelay: number; + memo?: string | undefined; + /** Channel-derivation salt; random when omitted. */ + salt?: bigint | undefined; + voucherSigner?: "client" | "server" | undefined; + operator?: string | undefined; + authorizationExpiresAt?: number | undefined; +} + +export interface BuiltDeposit { + channelId: string; + payload: BatchDepositPayload; + tracker: BatchChannelTracker; +} + +export async function buildDepositPayload(args: BuildDepositArgs): Promise { + if (args.firstCharge <= 0n || args.firstCharge > args.depositAmount) { + throw new Error("first charge must be positive and no greater than the deposit"); + } + if (args.openSlot > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("openSlot must fit in a JavaScript safe integer"); + } + const voucherSigner = args.voucherSigner ?? "client"; + const authorizedSigner = voucherSigner === "server" ? args.operator : args.payer.address; + if (!authorizedSigner) throw new Error("operator is required for operator voucher signing"); + const authorizationExpiresAt = args.authorizationExpiresAt; + let pendingAuthorization: { requestId: string; expiresAt: number } | undefined; + if (voucherSigner === "server") { + if ( + authorizationExpiresAt === undefined || + !Number.isSafeInteger(authorizationExpiresAt) || + authorizationExpiresAt <= 0 + ) { + throw new Error("authorizationExpiresAt is required for operator voucher signing"); + } + pendingAuthorization = { requestId: crypto.randomUUID(), expiresAt: authorizationExpiresAt }; + } + const open = await buildOpenPaymentChannelTransaction({ + authorizedSigner, + bindingMemo: encodeReceiverBindingMemo(args.receiverAuthorizer), + blockhash: args.blockhash, + deposit: args.depositAmount, + feePayer: args.feePayer, + gracePeriod: args.withdrawDelay, + memo: args.memo, + mint: args.mint, + openSlot: args.openSlot, + payee: args.feePayer, + payer: args.payer, + recipients: [{ bps: FULL_SPLIT_BPS, recipient: args.receiver }], + ...(args.salt !== undefined ? { salt: args.salt } : {}), + tokenProgram: args.tokenProgram, + }); + const channelConfig: BatchChannelConfig = { + openSlot: Number(open.openSlot), + payer: args.payer.address, + payerAuthorizer: authorizedSigner, + receiver: args.receiver, + receiverAuthorizer: args.receiverAuthorizer, + salt: open.salt.toString(), + token: args.mint, + withdrawDelay: args.withdrawDelay, + ...(voucherSigner === "server" ? { voucherSigner } : {}), + }; + const tracker = new BatchChannelTracker(open.channelId, channelConfig, args.payer); + // A payment payload is only an authorization. Do not advance local state + // until the resource server confirms it in PAYMENT-RESPONSE. + const credential = pendingAuthorization + ? await credentialFor("server", tracker, args.firstCharge, pendingAuthorization) + : await credentialFor("client", tracker, args.firstCharge); + return { + channelId: open.channelId, + payload: { + channelConfig, + deposit: { amount: args.depositAmount.toString(), transaction: open.transaction }, + type: "deposit", + ...credential, + }, + tracker, + }; +} + +// `blockhash` adds a payer-signed request_close for a facilitator that cannot +// close cooperatively. +export async function buildRefundPayload(args: { + payer: BatchClientSigner; + feePayer: string; + channelId: string; + channelConfig: BatchChannelConfig; + voucher?: BatchVoucher | undefined; + authorization?: BatchAuthorization | undefined; + blockhash?: { blockhash: string; lastValidBlockHeight: bigint } | undefined; + memo?: string | undefined; +}): Promise { + const serverMode = args.channelConfig.voucherSigner === "server"; + const credential = serverMode + ? { authorization: serverRefundAuthorization(args) } + : { voucher: clientRefundVoucher(args) }; + if (args.blockhash === undefined) { + return { channelConfig: args.channelConfig, type: "refund", ...credential }; + } + return { + channelConfig: args.channelConfig, + transaction: await buildRequestCloseTransaction({ + blockhash: args.blockhash, + channelId: args.channelId, + feePayer: args.feePayer, + memo: args.memo, + payer: args.payer, + }), + type: "refund", + ...credential, + }; +} + +function serverRefundAuthorization(args: { + authorization?: BatchAuthorization | undefined; + voucher?: BatchVoucher | undefined; +}): BatchAuthorization { + if (!args.authorization || args.voucher !== undefined) { + throw new Error("server-signed refund requires payer authorization only"); + } + return args.authorization; +} + +function clientRefundVoucher(args: { + authorization?: BatchAuthorization | undefined; + voucher?: BatchVoucher | undefined; +}): BatchVoucher { + if (!args.voucher || args.authorization !== undefined) { + throw new Error("client-signed refund requires a voucher"); + } + return args.voucher; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/constants.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/constants.ts new file mode 100644 index 0000000000..c3f0d4b8a4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/constants.ts @@ -0,0 +1,11 @@ +/** Deposit target when the client sets neither `depositAmount` nor a server hint. */ +export const DEFAULT_DEPOSIT_MULTIPLIER = 5; + +/** Smallest `depositMultiplier` the client accepts. */ +export const MIN_DEPOSIT_MULTIPLIER = 3; + +/** + * Splits a channel key from a request id. Neither side contains NUL, so the + * pair cannot collide with another channel's key. + */ +export const OPERATION_KEY_SEPARATOR = "\u0000"; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/index.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/index.ts new file mode 100644 index 0000000000..96f8da298a --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/index.ts @@ -0,0 +1,24 @@ +export { BatchSvmScheme } from "./scheme"; +export { + type BatchClientSigner, + BatchChannelTracker, + buildDepositPayload, + buildRefundPayload, + type BuildDepositArgs, + type BuiltDeposit, + signBatchVoucher, +} from "./channel"; +export type { BatchSvmClientConfig } from "./scheme"; +export { + type BatchServerSignedChannelsPolicy, + DEFAULT_SERVER_SIGNED_MAX_DEPOSIT, + isServerSignedAccept, + type ServerSignedChannelsAsset, + UntrustedOperatorError, +} from "./trust"; +export { + type BatchRefundOptions, + probeBatchRequirements, + refundBatchChannel, + type RefundPayloadOptions, +} from "./refund"; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/refund.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/refund.ts new file mode 100644 index 0000000000..5ebc7dbb51 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/refund.ts @@ -0,0 +1,212 @@ +/** Driving a payer-forced channel close over HTTP. */ + +import { + decodePaymentRequiredHeader, + decodePaymentResponseHeader, + encodePaymentSignatureHeader, +} from "@x402/core/http"; +import type { PaymentRequirements, SettleResponse } from "@x402/core/types"; + +import { BatchError } from "../errors"; +import { BATCH_SETTLEMENT_SCHEME, isBatchPayload, type BatchChannelConfig } from "../types"; + +/** + * Match probed 402 requirements to the channel the refund payload closes. + * + * A route may list server-signed before client-signed; the unpaid probe keeps + * the first Solana accept, but the close payload must bind to the channel's + * actual voucher-signer mode. + * + * @param probed - Requirements from an unpaid GET on the route + * @param channelConfig - Channel configuration carried by the refund payload + * @returns Requirements whose `extra.voucherSigner` matches `channelConfig` + */ +export function alignRefundRequirements( + probed: PaymentRequirements, + channelConfig: BatchChannelConfig, +): PaymentRequirements { + const mode = channelConfig.voucherSigner ?? "client"; + if ((probed.extra?.voucherSigner ?? "client") === mode) return probed; + const extra = { ...probed.extra }; + if (mode === "server") { + return { + ...probed, + extra: { + ...extra, + operator: channelConfig.payerAuthorizer, + voucherSigner: "server", + }, + }; + } + const { operator: _operator, ...clientExtra } = extra ?? {}; + void _operator; + return { ...probed, extra: { ...clientExtra, voucherSigner: "client" } }; +} + +/** + * Keep only Solana batch-settlement accepts from a 402's advertised list. + * + * @param accepts - Every accept from the unpaid 402 + * @returns Accepts whose scheme and network are batch settlement on Solana + */ +function batchSolanaAccepts(accepts: readonly PaymentRequirements[]): PaymentRequirements[] { + return accepts.filter( + accept => accept.scheme === BATCH_SETTLEMENT_SCHEME && accept.network.startsWith("solana:"), + ); +} + +/** + * Pick the server-advertised accept that matches the channel being closed. + * + * v2 matching requires the client's `accepted` to line up with one of the + * route's advertised accepts; mutating the first probe result is not enough. + * + * @param accepts - Every accept from the unpaid 402 + * @param channelConfig - Channel configuration carried by the refund payload + * @param probed - First Solana batch accept from the probe, for single-accept fallback + * @returns The advertised requirement to bind in PAYMENT-SIGNATURE + */ +export function selectRefundAccept( + accepts: readonly PaymentRequirements[], + channelConfig: BatchChannelConfig, + probed?: PaymentRequirements, +): PaymentRequirements { + const mode = channelConfig.voucherSigner ?? "client"; + const match = batchSolanaAccepts(accepts).find(accept => { + const signer = accept.extra?.voucherSigner ?? "client"; + if (signer !== mode) return false; + if (mode === "server") { + return accept.extra?.operator === channelConfig.payerAuthorizer; + } + return accept.extra?.operator === undefined; + }); + if (match) return match; + if (probed) return alignRefundRequirements(probed, channelConfig); + throw new Error(`no ${mode}-signed batch-settlement accept advertised for refund`); +} + +/** Caller-facing options for a refund. */ +export interface BatchRefundOptions { + /** Fetch implementation; defaults to `globalThis.fetch`. */ + fetch?: typeof fetch | undefined; + /** Requirements to refund against; probed from `url` when omitted. */ + requirements?: PaymentRequirements | undefined; +} + +/** How a refund payload is built. */ +export interface RefundPayloadOptions { + /** Include a payer-signed `request_close` for a facilitator that cannot close cooperatively. */ + withTransaction?: boolean | undefined; +} + +/** Builds the payer-signed close payload for a set of requirements. */ +export type RefundPayloadBuilder = ( + x402Version: number, + requirements: PaymentRequirements, + options?: RefundPayloadOptions, +) => Promise<{ x402Version: number; payload: unknown }>; + +/** + * Probe a protected route for the requirements its channel was opened against. + * + * A refund needs the same `feePayer`, asset and `withdrawDelay` the channel was + * derived from, and an unpaid `GET` is what advertises them. When the route + * lists more than one `batch-settlement` accept, this selects the `solana:` + * one. + * + * @param url - A protected route on the channel's server + * @param fetchImpl - Fetch implementation to probe with + * @returns The advertised Solana batch-settlement requirements and x402 version + */ +export async function probeBatchRequirements( + url: string, + fetchImpl: typeof fetch, +): Promise<{ + accepts: PaymentRequirements[]; + requirements: PaymentRequirements; + x402Version: number; +}> { + const probe = await fetchImpl(url, { method: "GET" }); + if (probe.status !== 402) { + throw new Error(`refund probe expected 402 from ${url}, got ${probe.status}`); + } + const header = probe.headers.get("PAYMENT-REQUIRED"); + if (!header) throw new Error("refund probe response has no PAYMENT-REQUIRED header"); + const paymentRequired = decodePaymentRequiredHeader(header); + const requirements = paymentRequired.accepts.find( + accept => accept.scheme === BATCH_SETTLEMENT_SCHEME && accept.network.startsWith("solana:"), + ); + if (!requirements) throw new Error(`${url} does not offer ${BATCH_SETTLEMENT_SCHEME}`); + return { + accepts: paymentRequired.accepts, + requirements, + x402Version: paymentRequired.x402Version, + }; +} + +/** + * Close the channel backing `url` and refund its unused escrow. + * + * The response is either an immediate cooperative close, or the start of a + * payer-forced close whose grace period must elapse before the unused deposit + * is returned. The scheme has no partial refund — the program returns all + * unused escrow or nothing — so this takes no amount. + * + * Unlike a paid request there is nothing to retry against a corrective 402: a + * close carries no cumulative amount to resynchronize. + * + * @param build - Builds the payer-signed close payload + * @param url - Any protected route on the channel to close + * @param options - Fetch override, or requirements to skip the probe + * @returns The settlement response describing the initiated close + */ +export async function refundBatchChannel( + build: RefundPayloadBuilder, + url: string, + options?: BatchRefundOptions, +): Promise { + const fetchImpl = options?.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new Error("refund requires a fetch implementation (globalThis.fetch unavailable)"); + } + const probed = options?.requirements + ? { accepts: [options.requirements], requirements: options.requirements, x402Version: 2 } + : await probeBatchRequirements(url, fetchImpl); + + const send = async (withTransaction: boolean) => { + const payload = await build(probed.x402Version, probed.requirements, { withTransaction }); + const raw = payload.payload; + if (!isBatchPayload(raw) || raw.type !== "refund") { + throw new Error("refund builder must return a batch-settlement refund payload"); + } + const accepted = selectRefundAccept(probed.accepts, raw.channelConfig, probed.requirements); + const response = await fetchImpl(url, { + headers: { + "PAYMENT-SIGNATURE": encodePaymentSignatureHeader({ + accepted, + payload: payload.payload as never, + x402Version: payload.x402Version, + }), + }, + method: "GET", + }); + const settledHeader = response.headers.get("PAYMENT-RESPONSE"); + const settled = settledHeader ? decodePaymentResponseHeader(settledHeader) : undefined; + const requiredHeader = + response.status === 402 ? response.headers.get("PAYMENT-REQUIRED") : null; + const reason = + settled?.errorReason ?? + (requiredHeader ? decodePaymentRequiredHeader(requiredHeader).error : undefined); + return { reason, settled, status: response.status }; + }; + + // Only a facilitator with no stored receiver binding needs the + // payer-signed request_close; everyone else closes cooperatively. + let result = await send(false); + if (result.reason === BatchError.RECEIVER_BINDING_UNAVAILABLE) result = await send(true); + if (result.settled) return result.settled; + if (result.status === 402) { + throw new Error(`refund refused: ${result.reason ?? "no reason given"}`); + } + throw new Error(`refund response has no PAYMENT-RESPONSE header (status ${result.status})`); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/scheme.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/scheme.ts new file mode 100644 index 0000000000..ef461d53f7 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/scheme.ts @@ -0,0 +1,1087 @@ +/* eslint-disable jsdoc/require-jsdoc, jsdoc/require-param-description, jsdoc/require-returns */ +import { type Address } from "@solana/kit"; +import { fetchMint } from "@solana-program/token-2022"; +import type { + PaymentPayloadContext, + PaymentPayload, + PaymentRequirements, + SchemeClientHooks, + SchemeNetworkClient, +} from "@x402/core/types"; + +import { findDefaultAsset } from "../../defaultAssets"; +import { buildTopUpPaymentChannelTransaction, parseU64 } from "../../payment-channels/open"; +import { requireTokenProgramHint } from "../../payment-channels/requirements"; +import { encodeVoucherMessageBytes, verifyVoucherSignature } from "../../payment-channels/voucher"; +import { discoverChannelsByPayer, type ProgramAccountScan } from "../../payment-channels/discovery"; +import { ChannelStatus } from "../../payment-channels/generated/types/channelStatus"; +import type { ClientSvmConfig } from "../../signer"; +import { createRpcClient, resolveBlockhash, resolveOpenSlot } from "../../utils"; +import { MAX_WITHDRAW_DELAY, MIN_WITHDRAW_DELAY } from "../constants"; +import { BatchError } from "../errors"; +import { + BATCH_SETTLEMENT_SCHEME, + isBatchPayload, + isBatchVoucher, + type BatchChannelState, + type BatchPayload, + type BatchVoucher, + type BatchVoucherState, +} from "../types"; +import { + type BatchClientSigner, + BatchChannelTracker, + buildDepositPayload, + buildRefundPayload, + credentialFor, +} from "./channel"; +import { + DEFAULT_DEPOSIT_MULTIPLIER, + MIN_DEPOSIT_MULTIPLIER, + OPERATION_KEY_SEPARATOR, +} from "./constants"; +import { + alignRefundRequirements, + type BatchRefundOptions, + type RefundPayloadOptions, + refundBatchChannel, +} from "./refund"; +import { + type BatchServerSignedChannelsPolicy, + type ResolvedServerSignedTrust, + ServerSignedTrustPolicy, + UntrustedOperatorError, +} from "./trust"; +import type { + OpenChannel, + PaymentResponseContext, + PendingChannel, + PendingPayment, + ResolvedTerms, +} from "./types"; + +/** A serializable, confirmed client channel allocation. */ +export interface BatchClientChannelRecord { + channelConfig: OpenChannel["tracker"]["channelConfig"]; + channelId: string; + chargedCumulativeAmount: string; + deposit: string; + /** Whether the top-level allocation is confirmed while `pending` is in flight. */ + hasConfirmedState?: boolean | undefined; + pending?: + | { + amount: string; + chargedCumulativeAmount: string; + deposit: string; + operationKey?: string | undefined; + payment: PendingPayment; + } + | Array<{ + amount: string; + chargedCumulativeAmount: string; + deposit: string; + operationKey: string; + payment: PendingPayment; + }>; +} + +/** Optional durable storage for confirmed client allocations. */ +export interface BatchClientChannelStorage { + get(key: string): Promise; + set(key: string, record: BatchClientChannelRecord): Promise; + delete(key: string): Promise; +} + +export interface BatchSvmClientConfig extends ClientSvmConfig { + /** Fixed deposit target. Overrides server hints and `depositPolicy`. */ + depositAmount?: bigint | string | undefined; + /** Policy used to size deposits when `depositAmount` is not set. */ + depositPolicy?: { depositMultiplier?: number | undefined } | undefined; + /** Persists confirmed state and a replayable pending allocation. */ + channelStorage?: BatchClientChannelStorage | undefined; + /** + * Channel-derivation salt. Defaults to `0`. + * + * A random salt would open a fresh channel on every start, stranding the + * escrow in the last one behind a forced close. Change it only to run + * several channels against the same server on purpose. + */ + salt?: bigint | string | undefined; + /** + * Scan the chain for a channel this wallet already opened when no local + * record exists. On by default; a client that keeps durable storage and + * wants to avoid the scan can turn it off. + */ + discoverChannels?: boolean | undefined; + /** + * Which resource operators may hold this client's voucher-signing authority, + * and how much escrow to lock under them. + * + * A 402 advertising `extra.voucherSigner: "server"` asks the client to open + * a channel whose onchain `authorized_signer` is the operator. Unless that + * key is listed in `allowedOperators` the client refuses the accept and, + * through its creation-failure hook, pays the same resource's client-signed + * accept instead when one is offered. Omit to never enter server mode. + */ + serverSignedChannelsPolicy?: BatchServerSignedChannelsPolicy | undefined; +} + +export class BatchSvmScheme implements SchemeNetworkClient { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + findDefaultAsset = findDefaultAsset; + readonly schemeHooks: SchemeClientHooks = { + onPaymentCreationFailure: async ctx => this.fallBackToClientSigned(ctx), + onPaymentResponse: async ctx => { + const recovered = await this.handlePaymentResponse(ctx); + return recovered ? { recovered: true } : undefined; + }, + }; + private readonly channels = new Map(); + private readonly pending = new Map(); + private readonly trust: ServerSignedTrustPolicy; + /** Spend-cap context core passed for an accept, reused when falling back to its client-signed twin. */ + private readonly creationContexts = new WeakMap(); + + constructor( + private readonly signer: BatchClientSigner, + private readonly config: BatchSvmClientConfig = {}, + ) { + const multiplier = config.depositPolicy?.depositMultiplier; + if ( + multiplier !== undefined && + (!Number.isInteger(multiplier) || multiplier < MIN_DEPOSIT_MULTIPLIER) + ) { + throw new Error(`depositMultiplier must be an integer >= ${MIN_DEPOSIT_MULTIPLIER}`); + } + this.trust = new ServerSignedTrustPolicy(config.serverSignedChannelsPolicy); + } + + /** + * Optional core `PaymentPolicy` (`x402Client.registerPolicy`) that drops + * untrusted server-signed accepts before selection and prefers trusted ones + * over the same route's client-signed accept, so a client that trusts an + * operator gets metered pricing even when the server lists the fixed-price + * accept first. Transport-agnostic: it reads only the accepts. + * + * Not required for safety. Without it the scheme still refuses untrusted + * accepts and falls back to the client-signed accept through its + * creation-failure hook. + * + * @param _x402Version - Protocol version (unused) + * @param accepts - Offered payment requirements + * @returns Filtered and reordered accepts + */ + readonly paymentPolicy = ( + _x402Version: number, + accepts: PaymentRequirements[], + ): PaymentRequirements[] => this.trust.filterAccepts(accepts); + + async createPaymentPayload( + x402Version: number, + requirements: PaymentRequirements, + context?: PaymentPayloadContext, + ): Promise> { + if (context) this.creationContexts.set(requirements, context); + const terms = await this.resolveTerms(requirements); + const charge = parseU64(requirements.amount, "amount"); + const authorizationExpiresAt = + Math.floor(Date.now() / 1000) + Math.max(1, requirements.maxTimeoutSeconds); + if (charge === 0n) throw new Error("batch-settlement amount must be positive"); + const key = this.channelKey(requirements, terms.feePayer, terms.withdrawDelay); + const existing = await this.loadChannel(key); + const channelPending = [...this.pending.values()].filter(candidate => candidate.key === key); + // Keep one in-flight allocation per channel. Server vouchers are cumulative, + // so serial requests are what let the client derive each charge from its + // exact locally confirmed watermark. + const blocking = channelPending[0]; + if (blocking) { + if (blocking.amount !== requirements.amount) { + throw new Error("batch-settlement channel has a pending allocation for a different amount"); + } + if (terms.voucherSigner === "server") { + throw new Error("batch-settlement server-signed channel has a pending request"); + } + return blocking.payment; + } + if (existing) { + const reserved = channelPending.reduce( + (sum, candidate) => sum + BigInt(candidate.amount), + 0n, + ); + const cumulative = existing.tracker.cumulative + reserved + charge; + if (cumulative <= existing.deposit) { + const requestId = terms.voucherSigner === "server" ? crypto.randomUUID() : undefined; + const payload: Extract = + requestId === undefined + ? { + channelConfig: existing.tracker.channelConfig, + type: "voucher", + voucher: (await credentialFor("client", existing.tracker, charge)).voucher, + } + : { + authorization: ( + await credentialFor("server", existing.tracker, charge, { + requestId, + expiresAt: authorizationExpiresAt, + }) + ).authorization, + channelConfig: existing.tracker.channelConfig, + type: "authorization", + }; + const payment: PendingPayment = { + x402Version, + payload, + }; + const next = { + ...existing, + amount: requirements.amount, + confirmed: existing, + cumulative, + key, + operationKey: requestId ? `${key}${OPERATION_KEY_SEPARATOR}${requestId}` : key, + payment, + }; + this.pending.set(next.operationKey, next); + await this.persistPending(next); + return payment; + } + if (channelPending.length > 0) { + throw new Error("batch-settlement channel has insufficient unreserved capacity"); + } + const topUpAmount = this.resolveDepositAmount( + requirements, + charge, + cumulative - existing.deposit, + context, + terms.trust, + existing.deposit, + ); + const rpc = createRpcClient(requirements.network, this.config.rpcUrl); + const blockhash = await resolveBlockhash(rpc, requirements); + const topUp = await buildTopUpPaymentChannelTransaction({ + amount: topUpAmount, + blockhash, + channelId: existing.tracker.channelId, + feePayer: terms.feePayer, + memo: terms.memo, + mint: requirements.asset, + payer: this.signer, + tokenProgram: terms.tokenProgram, + }); + const topUpRequestId = crypto.randomUUID(); + const topUpCredential = + terms.voucherSigner === "server" + ? await credentialFor("server", existing.tracker, charge, { + requestId: topUpRequestId, + expiresAt: authorizationExpiresAt, + }) + : await credentialFor("client", existing.tracker, charge); + const payment: PendingPayment = { + x402Version, + payload: { + channelConfig: existing.tracker.channelConfig, + deposit: { amount: topUpAmount.toString(), transaction: topUp.transaction }, + type: "deposit", + ...topUpCredential, + }, + }; + const next = { + ...existing, + amount: requirements.amount, + confirmed: existing, + cumulative, + deposit: existing.deposit + topUpAmount, + key, + operationKey: key, + payment, + }; + this.pending.set(next.operationKey, next); + await this.persistPending(next); + return payment; + } + + // Before funding a second channel, look for one this wallet already opened. + const discovered = await this.discoverChannel(requirements, terms); + if (discovered) { + this.channels.set(key, discovered); + await this.config.channelStorage?.set(key, this.toStorageRecord(discovered)); + return this.createPaymentPayload(x402Version, requirements, context); + } + + if ( + this.config.depositAmount !== undefined && + parseU64(this.config.depositAmount, "depositAmount") < charge + ) { + throw new Error("depositAmount must cover the current request"); + } + const deposit = this.resolveDepositAmount( + requirements, + charge, + charge, + context, + terms.trust, + 0n, + ); + const rpc = createRpcClient(requirements.network, this.config.rpcUrl); + const [blockhash, openSlot] = await Promise.all([ + resolveBlockhash(rpc, requirements), + resolveOpenSlot(rpc, requirements), + ]); + const built = await buildDepositPayload({ + blockhash, + depositAmount: deposit, + feePayer: terms.feePayer, + firstCharge: charge, + memo: terms.memo, + mint: requirements.asset, + openSlot, + payer: this.signer, + receiver: requirements.payTo, + receiverAuthorizer: terms.receiverAuthorizer, + salt: this.salt(), + tokenProgram: terms.tokenProgram, + withdrawDelay: terms.withdrawDelay, + voucherSigner: terms.voucherSigner, + ...(terms.voucherSigner === "server" ? { authorizationExpiresAt } : {}), + ...(terms.operator ? { operator: terms.operator } : {}), + }); + const payment: PendingPayment = { payload: built.payload, x402Version }; + const next = { + amount: requirements.amount, + cumulative: charge, + deposit, + key, + operationKey: built.payload.authorization + ? `${key}${OPERATION_KEY_SEPARATOR}${built.payload.authorization.requestId}` + : key, + payment, + tracker: built.tracker, + }; + this.pending.set(next.operationKey, next); + await this.persistPending(next); + return payment; + } + + /** + * Close the channel backing `url` and refund its unused escrow. + * + * Probes the route for the requirements the channel was opened against and + * sends a zero-charge voucher at the confirmed cumulative amount. The server + * closes the channel cooperatively, or, when the facilitator has no receiver + * binding, the payer-signed `request_close` starts a forced + * close whose escrow comes back after the grace period. + * + * @param url - Any protected route on the channel to close + * @param options - Fetch override, or requirements to skip the probe + * @returns The settlement response describing the close + */ + async refund(url: string, options?: BatchRefundOptions) { + return refundBatchChannel( + (x402Version, requirements, payloadOptions) => + this.createRefundPayload(x402Version, requirements, payloadOptions), + url, + options, + ); + } + + /** + * Build the payer-signed portable refund operation for the cached channel. + * + * @param x402Version + * @param requirements + * @param options - Whether to include a payer-signed `request_close` + */ + async createRefundPayload( + x402Version: number, + requirements: PaymentRequirements, + options?: RefundPayloadOptions, + ): Promise> { + const cached = this.findCachedChannelForRoute(requirements); + const lookupRequirements = cached + ? this.requirementsForRefund(requirements, cached) + : requirements; + const terms = await this.resolveRefundTerms(lookupRequirements, cached); + const key = this.channelKey(lookupRequirements, terms.feePayer, terms.withdrawDelay); + // A client with no local record is exactly the one that needs to close a + // channel it can no longer pay from, so fall back to the chain. + const existing = + (await this.loadChannel(key)) ?? + cached ?? + (await this.discoverChannel(lookupRequirements, terms)); + if (!existing) throw new Error("no batch-settlement channel to refund"); + const blockhash = options?.withTransaction + ? await resolveBlockhash( + createRpcClient(lookupRequirements.network, this.config.rpcUrl), + lookupRequirements, + ) + : undefined; + const serverMode = existing.tracker.channelConfig.voucherSigner === "server"; + const expiresAt = Math.floor(Date.now() / 1000) + lookupRequirements.maxTimeoutSeconds; + const credential = serverMode + ? await credentialFor("server", existing.tracker, 0n, { + requestId: crypto.randomUUID(), + expiresAt, + }) + : await credentialFor("client", existing.tracker, 0n, undefined, true); + return { + x402Version, + payload: await buildRefundPayload({ + blockhash, + channelConfig: existing.tracker.channelConfig, + channelId: existing.tracker.channelId, + feePayer: terms.feePayer, + memo: terms.memo, + payer: this.signer, + ...credential, + }), + }; + } + + /** + * Pay the same resource client-signed when the selected accept needed an + * operator this client does not trust. + * + * The fallback is restricted to a client-signed batch-settlement accept on + * the same network and asset for no more than the refused accept's amount, + * so it can never widen what the client's spend controls already allowed + * for the selected accept; the spend-cap context core resolved for that + * accept is reused as-is. + * + * @param ctx - Core's creation-failure context + * @returns A recovered payload for the client-signed accept, or nothing + */ + private async fallBackToClientSigned( + ctx: Parameters>[0], + ): Promise { + if (!(ctx.error instanceof UntrustedOperatorError)) return undefined; + const refused = ctx.selectedRequirements; + if (!/^\d+$/.test(refused.amount)) return undefined; + const fallback = ctx.paymentRequired.accepts.find( + accept => + accept !== refused && + accept.scheme === BATCH_SETTLEMENT_SCHEME && + accept.network === refused.network && + accept.asset === refused.asset && + (accept.extra?.voucherSigner ?? "client") === "client" && + /^\d+$/.test(accept.amount) && + BigInt(accept.amount) <= BigInt(refused.amount), + ); + if (!fallback) return undefined; + const partial = await this.createPaymentPayload( + ctx.paymentRequired.x402Version, + fallback, + this.creationContexts.get(refused), + ); + return { + recovered: true, + payload: { + ...partial, + accepted: fallback, + resource: ctx.paymentRequired.resource, + ...(ctx.paymentRequired.extensions ? { extensions: ctx.paymentRequired.extensions } : {}), + }, + }; + } + + private salt(): bigint { + return this.config.salt === undefined ? 0n : parseU64(this.config.salt, "salt"); + } + + private resolveDepositAmount( + requirements: PaymentRequirements, + requestAmount: bigint, + needed: bigint, + context: PaymentPayloadContext | undefined, + trust: ResolvedServerSignedTrust | undefined, + existingDeposit: bigint, + ): bigint { + const multiplier = this.config.depositPolicy?.depositMultiplier ?? DEFAULT_DEPOSIT_MULTIPLIER; + const configured = + this.config.depositAmount === undefined + ? undefined + : parseU64(this.config.depositAmount, "depositAmount"); + const announced = parseAnnouncedMinDeposit(requirements.extra?.minDeposit, requestAmount); + const target = configured ?? announced ?? requestAmount * BigInt(multiplier); + let proposed = target > needed ? target : needed; + const maxDeposit = maxDepositFromSpendCap(context?.maxAmountPerPayment, multiplier); + if (maxDeposit !== undefined && needed > maxDeposit) { + throw new Error( + `Required deposit ${needed} exceeds depositMultiplier × spendControls.maxAmountPerPayment (${maxDeposit}). ` + + "Raise maxAmountPerPayment or depositMultiplier.", + ); + } + if (maxDeposit !== undefined && proposed > maxDeposit) proposed = maxDeposit; + // In server mode the escrow is what a dishonest operator could take, so + // the trust grant's cap wins over every hint, including the server's own + // `minDeposit`, and over this client's fixed `depositAmount`. + if (trust?.maxDeposit !== undefined) { + const room = trust.maxDeposit - existingDeposit; + if (needed > room) { + throw new Error( + `Required deposit ${needed} exceeds the remaining serverSignedChannelsPolicy maxDeposit ` + + `(${trust.maxDeposit} total, ${existingDeposit} already escrowed). ` + + "Raise maxDeposit for this operator or use a client-signed accept.", + ); + } + if (proposed > room) proposed = room; + } + return proposed; + } + + /** + * Find a channel this wallet already opened against these terms. + * + * A client with no local record would otherwise open a second channel and + * leave the first one's escrow to a forced close. The scan is filtered on + * `payer`, and every row's PDA is rederived from its own fields before it is + * trusted, so a crafted account cannot pass itself off as a channel. + * + * The adopted cumulative base is the onchain settled watermark: the charges + * above it exist only in vouchers this client no longer has, and the server + * rebuilds from the same watermark, so both sides agree. + * + * @param requirements + * @param terms + * @param terms.feePayer + * @param terms.withdrawDelay + * @param terms.tokenProgram + * @param terms.receiverAuthorizer + * @param terms.voucherSigner + * @param terms.operator + */ + private async discoverChannel( + requirements: PaymentRequirements, + terms: ResolvedTerms, + ): Promise { + if (this.config.discoverChannels === false) return undefined; + const rpc = createRpcClient(requirements.network, this.config.rpcUrl); + const scan: ProgramAccountScan = async (programId, filters) => + (await rpc + .getProgramAccounts(programId as Address, { + commitment: "confirmed", + encoding: "base64", + filters: filters as never, + }) + .send()) as never; + let found; + try { + found = await discoverChannelsByPayer(scan, this.signer.address); + } catch { + // Discovery is an optimization over opening a new channel, never a + // precondition for paying. + return undefined; + } + const salt = this.salt(); + const usable = found.filter( + candidate => + candidate.channel.status === ChannelStatus.Open && + candidate.channel.closureStartedAt === 0n && + candidate.channel.payee === terms.feePayer && + candidate.channel.mint === requirements.asset && + candidate.channel.authorizedSigner === (terms.operator ?? this.signer.address) && + candidate.channel.gracePeriod === terms.withdrawDelay && + candidate.channel.salt === salt, + ); + // Prefer the newest, so a channel opened after an earlier one was drained + // wins. + usable.sort((left, right) => (left.channel.openSlot < right.channel.openSlot ? 1 : -1)); + const channel = usable[0]; + if (!channel) return undefined; + return { + deposit: channel.channel.deposit, + tracker: new BatchChannelTracker( + channel.channelId, + { + openSlot: Number(channel.channel.openSlot), + payer: channel.channel.payer, + payerAuthorizer: channel.channel.authorizedSigner, + receiver: requirements.payTo, + receiverAuthorizer: terms.receiverAuthorizer, + salt: channel.channel.salt.toString(), + token: channel.channel.mint, + withdrawDelay: channel.channel.gracePeriod, + ...(terms.voucherSigner === "server" ? { voucherSigner: "server" as const } : {}), + }, + this.signer, + channel.channel.settlement.settled, + ), + }; + } + + private async loadChannel(key: string): Promise { + const cached = this.channels.get(key); + if (cached) return cached; + const saved = await this.config.channelStorage?.get(key); + if (!saved) return undefined; + if (saved.pending) { + const savedPending = Array.isArray(saved.pending) ? saved.pending : [saved.pending]; + if (savedPending.length === 0) return this.hydrateAndCache(key, saved); + const confirmed = saved.hasConfirmedState ? this.hydrateChannel(saved) : undefined; + if (confirmed) this.channels.set(key, confirmed); + const tracker = + confirmed?.tracker ?? + new BatchChannelTracker(saved.channelId, saved.channelConfig, this.signer); + for (const pending of savedPending) { + const operationKey = pending.operationKey ?? key; + this.pending.set(operationKey, { + confirmed, + tracker, + amount: pending.amount, + cumulative: parseU64(pending.chargedCumulativeAmount, "stored pending cumulative"), + deposit: parseU64(pending.deposit, "stored pending deposit"), + key, + operationKey, + payment: pending.payment, + }); + } + return confirmed; + } + return this.hydrateAndCache(key, saved); + } + + private hydrateAndCache(key: string, record: BatchClientChannelRecord): OpenChannel { + const channel = this.hydrateChannel(record); + this.channels.set(key, channel); + return channel; + } + + private persistPending(pending: PendingChannel): Promise | undefined { + const allPending = [...this.pending.values()].filter( + candidate => candidate.key === pending.key, + ); + const confirmed = this.channels.get(pending.key) ?? pending.confirmed; + const recordChannel = confirmed ?? { + deposit: pending.deposit ?? 0n, + tracker: pending.tracker, + }; + return this.config.channelStorage?.set( + pending.key, + this.toStorageRecord(recordChannel, { + hasConfirmedState: confirmed !== undefined, + pending: allPending.map(item => ({ + amount: item.amount, + chargedCumulativeAmount: item.cumulative.toString(), + deposit: item.deposit.toString(), + operationKey: item.operationKey, + payment: item.payment, + })), + }), + ); + } + + /** + * Reconcile local channel state with the server's answer. + * + * Returns whether the client resynchronized and the request should be + * retried. + * + * @param ctx + */ + private async handlePaymentResponse(ctx: PaymentResponseContext): Promise { + const payload = ctx.paymentPayload.payload; + if ( + !isBatchPayload(payload) || + (payload.type !== "authorization" && payload.type !== "voucher" && payload.type !== "deposit") + ) { + return false; + } + const requestId = + payload.type === "authorization" + ? payload.authorization.requestId + : payload.type === "deposit" + ? payload.authorization?.requestId + : undefined; + const voucherChannelId = + payload.type === "voucher" + ? payload.voucher.channelId + : payload.type === "deposit" + ? payload.voucher?.channelId + : undefined; + const findPending = () => + [...this.pending.values()].find(candidate => + requestId + ? candidate.payment.payload.type !== "voucher" && + candidate.payment.payload.authorization?.requestId === requestId + : candidate.tracker.channelId === voucherChannelId, + ); + let pending = findPending(); + if (!pending && this.config.channelStorage) { + // A recovered HTTP response can be the first call on a fresh client. + // Restore its exact pending allocation without creating another request. + const terms = await this.resolveTerms(ctx.requirements); + await this.loadChannel( + this.channelKey(ctx.requirements, terms.feePayer, terms.withdrawDelay), + ); + pending = findPending(); + } + if (!pending) return false; + this.pending.delete(pending.operationKey); + + if (!ctx.settleResponse?.success) { + await this.restoreConfirmedChannel(pending); + // A corrective 402 carries the base the server is actually charging + // from. Adopting it — against this client's own signature — turns a + // dead channel back into a usable one. + return ctx.paymentRequired ? this.adoptCorrectiveState(pending, ctx.paymentRequired) : false; + } + + // The response is the server's report, not this client's accounting. The + // charge is capped at the price this request advertised, the cumulative is + // computed locally and only cross-checked against the server's, and the + // escrow comes from the deposit this client itself signed. + const extra = ctx.settleResponse.extra as + | { + commitmentId?: unknown; + chargedAmount?: unknown; + channelState?: { balance?: unknown; chargedCumulativeAmount?: unknown }; + voucher?: BatchVoucher; + } + | undefined; + const requestAmount = parseU64(ctx.requirements.amount, "requirements.amount"); + const localPrior = pending.confirmed?.tracker.cumulative ?? 0n; + let charged: bigint; + let confirmedCumulative: bigint; + if (pending.tracker.channelConfig.voucherSigner === "server") { + const voucher = extra?.voucher; + const cumulative = + isBatchVoucher(voucher) && /^\d+$/.test(voucher.maxClaimableAmount) + ? BigInt(voucher.maxClaimableAmount) + : undefined; + if ( + !isBatchVoucher(voucher) || + cumulative === undefined || + voucher.channelId !== pending.tracker.channelId || + voucher.expiresAt !== 0 || + !(await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId: pending.tracker.channelId, + cumulativeAmount: cumulative ?? 0n, + expiresAt: BigInt(voucher.expiresAt), + }), + signatureBase58: voucher.signature, + signerBase58: pending.tracker.channelConfig.payerAuthorizer, + })) + ) { + await this.restoreConfirmedChannel(pending); + throw new Error("batch-settlement PAYMENT-RESPONSE has an invalid server voucher"); + } + if (cumulative < localPrior || cumulative - localPrior > requestAmount) { + await this.restoreConfirmedChannel(pending); + return false; + } + charged = cumulative - localPrior; + confirmedCumulative = localPrior + charged; + } else { + charged = + typeof extra?.chargedAmount === "string" && /^\d+$/.test(extra.chargedAmount) + ? BigInt(extra.chargedAmount) + : -1n; + if (charged !== requestAmount) { + throw new Error("batch-settlement PAYMENT-RESPONSE charged an unexpected amount"); + } + confirmedCumulative = localPrior + charged; + } + const reported = extra?.channelState?.chargedCumulativeAmount; + if ( + // The commitment identifier is opaque to the client: the spec only + // requires it to be non-empty (section 4.4). The server's own cumulative, + // when reported, must still agree with the one derived here. + typeof extra?.commitmentId !== "string" || + extra.commitmentId === "" || + (typeof reported === "string" && reported !== confirmedCumulative.toString()) + ) { + // The server confirmed something this client did not submit. Leave local + // state untouched rather than adopt an accounting it cannot derive; the + // next request resynchronizes through a corrective 402. + await this.restoreConfirmedChannel(pending); + return false; + } + // A deposit's escrow is what this client signed for, not what the server + // reports holding. + const deposited = + payload.type === "deposit" ? parseU64(payload.deposit.amount, "deposit.amount") : 0n; + if (confirmedCumulative > pending.tracker.cumulative) { + pending.tracker.commit(confirmedCumulative); + } + pending.deposit = (pending.confirmed?.deposit ?? 0n) + deposited; + const confirmed = { deposit: pending.deposit, tracker: pending.tracker }; + this.channels.set(pending.key, confirmed); + const remaining = [...this.pending.values()].find(candidate => candidate.key === pending.key); + if (remaining) await this.persistPending(remaining); + else await this.config.channelStorage?.set(pending.key, this.toStorageRecord(confirmed)); + return false; + } + + /** + * Adopt the cumulative base from a corrective 402. + * + * The snapshot is only the server's word for what it has charged, so it is + * accepted only against `voucherState`: an Ed25519 signature over the + * 50-byte voucher message, which only this client's own authorizer key could + * have produced. Without that proof the server has no accepted voucher, and + * the client resynchronizes from the onchain settled watermark instead. + * + * @param pending + * @param paymentRequired + * @param paymentRequired.error + * @param paymentRequired.accepts + */ + private async adoptCorrectiveState( + pending: PendingChannel, + paymentRequired: { error?: string | undefined; accepts: PaymentRequirements[] }, + ): Promise { + if (paymentRequired.error !== BatchError.CUMULATIVE_AMOUNT_MISMATCH) return false; + const accept = paymentRequired.accepts.find( + candidate => + candidate.scheme === BATCH_SETTLEMENT_SCHEME && + (candidate.extra?.channelState as BatchChannelState | undefined)?.channelId === + pending.tracker.channelId, + ); + const channelState = accept?.extra?.channelState as BatchChannelState | undefined; + if (!channelState?.chargedCumulativeAmount) return false; + const charged = parseU64(channelState.chargedCumulativeAmount, "chargedCumulativeAmount"); + const claimed = parseU64(channelState.totalClaimed, "totalClaimed"); + // A server may never claim to have charged less than the chain has already + // settled, nor more than the client signed for. + if (charged < claimed) return false; + if (pending.tracker.channelConfig.voucherSigner === "server") { + // In server mode `voucherState` is signed by the operator, so its + // signature proves nothing about what this client authorized. The only + // bound the client can assert itself is what it agreed to: its confirmed + // watermark plus the ceilings of its own requests whose outcome it never + // saw. Anything above that is an operator claim it has no basis to adopt. + const unresolved = [...this.pending.values()] + .filter(candidate => candidate.key === pending.key) + .reduce((sum, candidate) => sum + parseU64(candidate.amount, "pending amount"), 0n); + const authorized = + (pending.confirmed?.tracker.cumulative ?? 0n) + + parseU64(pending.amount, "pending amount") + + unresolved; + if (charged > authorized) return false; + } + + const voucherState = accept?.extra?.voucherState as BatchVoucherState | undefined; + if (voucherState) { + const signed = parseU64(voucherState.signedMaxClaimable, "signedMaxClaimable"); + if (charged > signed) return false; + const verified = await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId: pending.tracker.channelId, + cumulativeAmount: signed, + expiresAt: BigInt(voucherState.expiresAt), + }), + signatureBase58: voucherState.signature, + signerBase58: pending.tracker.channelConfig.payerAuthorizer, + }); + if (!verified) return false; + } else if (charged !== claimed) { + // No proof: the only base a client may adopt unproven is the one the + // chain itself reports as settled. + return false; + } + + const adopted: OpenChannel = { + deposit: parseU64(channelState.balance, "channelState.balance"), + tracker: new BatchChannelTracker( + pending.tracker.channelId, + pending.tracker.channelConfig, + this.signer, + charged, + ), + }; + this.channels.set(pending.key, adopted); + await this.config.channelStorage?.set(pending.key, this.toStorageRecord(adopted)); + return true; + } + + private hydrateChannel(record: BatchClientChannelRecord): OpenChannel { + return { + deposit: parseU64(record.deposit, "stored deposit"), + tracker: new BatchChannelTracker( + record.channelId, + record.channelConfig, + this.signer, + parseU64(record.chargedCumulativeAmount, "stored chargedCumulativeAmount"), + ), + }; + } + + private toStorageRecord( + channel: OpenChannel, + options?: { hasConfirmedState?: boolean; pending?: BatchClientChannelRecord["pending"] }, + ): BatchClientChannelRecord { + return { + channelConfig: channel.tracker.channelConfig, + channelId: channel.tracker.channelId, + chargedCumulativeAmount: channel.tracker.cumulative.toString(), + deposit: channel.deposit.toString(), + ...(options?.hasConfirmedState ? { hasConfirmedState: true } : {}), + ...(options?.pending ? { pending: options.pending } : {}), + }; + } + + private findCachedChannelForRoute(requirements: PaymentRequirements): OpenChannel | undefined { + for (const channel of this.channels.values()) { + const config = channel.tracker.channelConfig; + if (config.receiver === requirements.payTo && config.token === requirements.asset) { + return channel; + } + } + return undefined; + } + + /** + * Align probed refund requirements with how the open channel was authorized. + * + * A refund probe takes the first Solana accept, which may be client-signed + * even when this wallet paid through a server-signed accept on the same route. + * + * @param probed + * @param cached + */ + private async resolveRefundTerms( + probed: PaymentRequirements, + cached?: OpenChannel, + ): Promise { + if (cached?.tracker.channelConfig.voucherSigner === "server") { + const clientProbed: PaymentRequirements = { + ...probed, + extra: { + ...probed.extra, + operator: undefined, + voucherSigner: "client", + }, + }; + const terms = await this.resolveTerms(clientProbed); + return { + ...terms, + operator: cached.tracker.channelConfig.payerAuthorizer, + voucherSigner: "server", + }; + } + return this.resolveTerms(probed); + } + + private requirementsForRefund( + probed: PaymentRequirements, + cached: OpenChannel, + ): PaymentRequirements { + return alignRefundRequirements(probed, cached.tracker.channelConfig); + } + + private async restoreConfirmedChannel(pending: PendingChannel): Promise { + const remaining = [...this.pending.values()].find(candidate => candidate.key === pending.key); + if (remaining) { + await this.persistPending(remaining); + return; + } + if (!pending.confirmed) { + await this.config.channelStorage?.delete(pending.key); + return; + } + this.channels.set(pending.key, pending.confirmed); + await this.config.channelStorage?.set(pending.key, this.toStorageRecord(pending.confirmed)); + } + + private channelKey( + requirements: PaymentRequirements, + feePayer: string, + withdrawDelay: number, + ): string { + return [ + requirements.network, + requirements.asset, + requirements.payTo, + feePayer, + withdrawDelay, + requirements.extra?.receiverAuthorizer ?? "", + requirements.extra?.voucherSigner ?? "client", + requirements.extra?.operator ?? "", + ].join(":"); + } + + private async resolveTerms(requirements: PaymentRequirements): Promise { + const extra = requirements.extra; + if (!extra) throw new Error("requirements.extra is required"); + if (extra.paymentFlow !== undefined && extra.paymentFlow !== "authorization") { + throw new Error('extra.paymentFlow must be "authorization" when present'); + } + const feePayer = extra.feePayer; + if (typeof feePayer !== "string" || feePayer.length === 0) { + throw new Error("extra.feePayer must be a non-empty string"); + } + const withdrawDelay = extra.withdrawDelay; + if ( + typeof withdrawDelay !== "number" || + !Number.isInteger(withdrawDelay) || + withdrawDelay < MIN_WITHDRAW_DELAY || + withdrawDelay > MAX_WITHDRAW_DELAY || + withdrawDelay < requirements.maxTimeoutSeconds + ) { + throw new Error("extra.withdrawDelay is outside the allowed range"); + } + const tokenProgram = requireTokenProgramHint( + extra, + "extra.tokenProgram is not a supported SPL token program", + ); + const rpc = createRpcClient(requirements.network, this.config.rpcUrl); + const mint = await fetchMint(rpc, requirements.asset as Address); + if (mint.programAddress.toString() !== tokenProgram) { + throw new Error("extra.tokenProgram does not own requirements.asset"); + } + const receiverAuthorizer = extra.receiverAuthorizer; + if (typeof receiverAuthorizer !== "string" || receiverAuthorizer.length === 0) { + throw new Error("extra.receiverAuthorizer must be a non-empty string"); + } + const memo = extra.memo; + if (memo !== undefined && typeof memo !== "string") { + throw new Error("extra.memo must be a string when present"); + } + const voucherSigner = extra.voucherSigner ?? "client"; + if (voucherSigner !== "client" && voucherSigner !== "server") { + throw new Error('extra.voucherSigner must be "client" or "server"'); + } + const operator = extra.operator; + if (voucherSigner === "server" && (typeof operator !== "string" || operator.length === 0)) { + throw new Error("extra.operator is required for operator voucher signing"); + } + if (voucherSigner === "client" && operator !== undefined) { + throw new Error("extra.operator is only valid for operator voucher signing"); + } + // Server mode hands the operator this client's onchain signing authority. + // That is never implied by a 402; it has to be a key this client listed, + // and `grantFor` throws `UntrustedOperatorError` otherwise so the + // creation-failure hook can fall back to a client-signed accept. + const trust = voucherSigner === "server" ? this.trust.grantFor(requirements) : undefined; + return { + feePayer, + ...(memo !== undefined ? { memo } : {}), + receiverAuthorizer, + tokenProgram, + withdrawDelay, + voucherSigner, + ...(typeof operator === "string" ? { operator } : {}), + ...(trust ? { trust } : {}), + }; + } +} + +function parseAnnouncedMinDeposit(value: unknown, requestAmount: bigint): bigint | undefined { + if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined; + const parsed = BigInt(value); + return parsed >= requestAmount && parsed > 0n ? parsed : undefined; +} + +function maxDepositFromSpendCap( + maxAmountPerPayment: string | undefined, + depositMultiplier: number, +): bigint | undefined { + if ( + maxAmountPerPayment === undefined || + !/^\d+$/.test(maxAmountPerPayment) || + BigInt(maxAmountPerPayment) <= 0n + ) { + return undefined; + } + return BigInt(maxAmountPerPayment) * BigInt(depositMultiplier); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/trust.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/trust.ts new file mode 100644 index 0000000000..da7cbbad75 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/trust.ts @@ -0,0 +1,257 @@ +/** + * Client-side trust policy for server-signed batch-settlement channels. + * + * In server mode the channel's onchain `authorized_signer` is the resource + * operator, so the operator can sign a voucher for the full unspent deposit + * without any further client signature. A client must therefore never enter + * that mode because a 402 asked for it; it enters only for operator keys it + * has decided to trust out of band, and only up to an escrow it chose. + * + * The policy mirrors the core `spendControls` shape: a USD cap that applies to + * assets `findDefaultAsset` recognizes, and an opt-in list of other assets + * with integer atomic caps. Trust is keyed by operator key alone, so it works + * the same over HTTP, MCP, or any other transport. + */ + +import type { Money, Network, PaymentRequirements } from "@x402/core/types"; +import { convertToTokenAmount, networkMatchesPattern, parseMoney } from "@x402/core/utils"; + +import { findDefaultAsset } from "../../defaultAssets"; +import { BATCH_SETTLEMENT_SCHEME } from "../types"; + +/** Default escrow cap for default assets under a trusted operator. */ +export const DEFAULT_SERVER_SIGNED_MAX_DEPOSIT: Money = "$1"; + +/** Opt-in asset for {@link BatchServerSignedChannelsPolicy.allowedAssets}. */ +export interface ServerSignedChannelsAsset { + network: Network; + /** On-chain mint, or a default-asset symbol (e.g. `"USDC"`). */ + asset: string; + /** Optional integer atomic escrow cap (e.g. `"5000000"`), not `"$1"`. Omit to leave it uncapped. */ + maxDeposit?: string | undefined; +} + +/** + * Which operators may hold this client's voucher-signing authority, and how + * much escrow it will lock under them. + */ +export interface BatchServerSignedChannelsPolicy { + /** + * Base58 operator keys this client trusts. A server-signed accept whose + * `extra.operator` is not listed is refused and, when the same resource is + * also offered client-signed, paid that way instead. + */ + allowedOperators: readonly string[]; + /** + * USD cap on the total escrow (deposit plus top-ups) locked in one channel + * under a trusted operator, for assets `findDefaultAsset` recognizes. This is + * the amount a dishonest operator could take, so server `minDeposit` hints + * above it are clamped, not honored. `false` disables the cap. + * + * @default "$1" + */ + maxDeposit?: Money | false | undefined; + /** + * Opt-in non-default assets, each with an optional integer atomic cap. A + * server-signed accept for an asset that is neither a default asset nor + * listed here is refused. + */ + allowedAssets?: readonly ServerSignedChannelsAsset[] | undefined; +} + +/** A matched grant, with the cap resolved to atomic units for the accept's asset. */ +export interface ResolvedServerSignedTrust { + operator: string; + maxDeposit?: bigint | undefined; +} + +/** + * A server-signed accept this client will not act on. The scheme's + * creation-failure hook recognizes it and falls back to the same resource's + * client-signed accept when one is offered. + */ +export class UntrustedOperatorError extends Error { + /** + * Build the refusal with the operator it concerns, so a fallback can name it. + * + * @param message - Actionable explanation of the refusal + * @param operator - The advertised operator key, when the accept carried one + */ + constructor( + message: string, + readonly operator: string | undefined, + ) { + super(message); + this.name = "UntrustedOperatorError"; + } +} + +/** + * Whether an accept asks the client to delegate voucher signing to the operator. + * + * @param accept - One entry of `PaymentRequired.accepts` + * @returns True for a batch-settlement accept in server mode + */ +export function isServerSignedAccept(accept: PaymentRequirements): boolean { + return accept.scheme === BATCH_SETTLEMENT_SCHEME && accept.extra?.voucherSigner === "server"; +} + +/** + * Decides which server-signed accepts a client may act on, and up to what escrow. + */ +export class ServerSignedTrustPolicy { + private readonly operators: ReadonlySet; + private readonly usdCap: string | false; + private readonly assets: readonly ServerSignedChannelsAsset[]; + + /** + * Validate and normalize the configured policy. + * + * @param policy - `serverSignedChannelsPolicy`, or undefined to trust nobody + */ + constructor(policy?: BatchServerSignedChannelsPolicy | undefined) { + const label = "serverSignedChannelsPolicy"; + const operators = policy?.allowedOperators ?? []; + operators.forEach((operator, index) => { + if (typeof operator !== "string" || operator.length === 0) { + throw new Error(`${label}.allowedOperators[${index}] must be a non-empty base58 key`); + } + }); + this.operators = new Set(operators); + if (policy?.maxDeposit === false) { + this.usdCap = false; + } else { + const money = policy?.maxDeposit ?? DEFAULT_SERVER_SIGNED_MAX_DEPOSIT; + const { amount } = parseMoney(money); + if (Number(amount) <= 0) throw new Error(`${label}.maxDeposit must be positive`); + this.usdCap = amount; + } + this.assets = policy?.allowedAssets ?? []; + this.assets.forEach((entry, index) => { + if (entry.maxDeposit !== undefined && !/^[1-9]\d*$/.test(entry.maxDeposit)) { + throw new Error( + `${label}.allowedAssets[${index}].maxDeposit must be a positive integer atomic amount, not a dollar value; got ${JSON.stringify(entry.maxDeposit)}`, + ); + } + }); + } + + /** + * The grant under which this client may pay a server-signed accept. + * + * @param requirements - Selected accept + * @returns The operator and the atomic escrow cap for this accept's asset + * @throws UntrustedOperatorError when the operator is not allowed or the asset is not permitted + */ + grantFor(requirements: PaymentRequirements): ResolvedServerSignedTrust { + const operator = requirements.extra?.operator; + if (typeof operator !== "string" || !this.operators.has(operator)) { + throw new UntrustedOperatorError( + untrustedOperatorMessage(typeof operator === "string" ? operator : undefined), + typeof operator === "string" ? operator : undefined, + ); + } + const defaultAsset = findDefaultAsset(requirements.asset, requirements.network); + const entry = this.assets.find( + candidate => + networkMatchesPattern(candidate.network, requirements.network) && + (candidate.asset.toLowerCase() === requirements.asset.toLowerCase() || + (defaultAsset !== undefined && + defaultAsset.symbol.toLowerCase() === candidate.asset.toLowerCase())), + ); + if (entry) { + return { + operator, + ...(entry.maxDeposit !== undefined ? { maxDeposit: BigInt(entry.maxDeposit) } : {}), + }; + } + if (!defaultAsset) { + throw new UntrustedOperatorError( + `batch-settlement: ${requirements.asset} on ${requirements.network} is not a default asset. ` + + "Add it to serverSignedChannelsPolicy.allowedAssets with an atomic maxDeposit before " + + `locking escrow under operator ${operator}.`, + operator, + ); + } + if (this.usdCap === false) return { operator }; + return { + operator, + maxDeposit: BigInt(convertToTokenAmount(this.usdCap, defaultAsset.decimals)), + }; + } + + /** + * Filter a 402's accepts before payment selection. + * + * Server-signed accepts this client does not trust are removed, so the core + * falls back to whatever else the server offered (typically the same route + * in client mode). Trusted server-signed accepts are moved ahead of other + * batch-settlement accepts on the same network so the default selector + * picks metered pricing where the client has chosen to allow it. Accepts of + * other schemes keep their positions. + * + * Usable directly as a core `PaymentPolicy`. Without it the scheme still + * refuses untrusted accepts and falls back through its creation-failure + * hook; this policy only adds the preference for trusted metered accepts. + * + * @param accepts - Offered payment requirements + * @returns The filtered, reordered accepts + * @throws UntrustedOperatorError when every accept required an untrusted operator + */ + filterAccepts(accepts: readonly PaymentRequirements[]): PaymentRequirements[] { + const trusted = new Set(); + const refused: string[] = []; + const remaining = accepts.filter(accept => { + if (!isServerSignedAccept(accept)) return true; + try { + this.grantFor(accept); + trusted.add(accept); + return true; + } catch (error) { + if (!(error instanceof UntrustedOperatorError)) throw error; + refused.push(error.operator ?? ""); + return false; + } + }); + if (remaining.length === 0 && refused.length > 0) { + throw new UntrustedOperatorError(untrustedOperatorMessage(...refused), refused[0]); + } + if (trusted.size === 0) return remaining; + const reordered: PaymentRequirements[] = []; + const networksSeen = new Set(); + for (const accept of remaining) { + if (accept.scheme === BATCH_SETTLEMENT_SCHEME && !networksSeen.has(accept.network)) { + networksSeen.add(accept.network); + for (const candidate of remaining) { + if ( + candidate.scheme === BATCH_SETTLEMENT_SCHEME && + candidate.network === accept.network && + trusted.has(candidate) + ) { + reordered.push(candidate); + } + } + } + if (trusted.has(accept)) continue; + reordered.push(accept); + } + return reordered; + } +} + +/** + * Build the refusal message for an untrusted server-signed accept. + * + * @param operators - Operators the server advertised + * @returns Human-readable, actionable error text + */ +export function untrustedOperatorMessage(...operators: readonly (string | undefined)[]): string { + const keys = operators.filter((key): key is string => key !== undefined); + const who = keys.length > 0 ? keys.join(", ") : ""; + return ( + `batch-settlement: this resource requires a server-signed channel whose operator (${who}) ` + + "can claim up to the full channel deposit without further client signatures. " + + "Trust it explicitly by listing the key in serverSignedChannelsPolicy.allowedOperators, " + + "and bound what it could take with serverSignedChannelsPolicy.maxDeposit." + ); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/client/types.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/client/types.ts new file mode 100644 index 0000000000..1a75addfa0 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/client/types.ts @@ -0,0 +1,46 @@ +import type { SchemeClientHooks } from "@x402/core/types"; + +import type { BatchPayload } from "../types"; +import type { BatchChannelTracker } from "./channel"; +import type { ResolvedServerSignedTrust } from "./trust"; + +/** A confirmed client allocation for one channel. */ +export interface OpenChannel { + tracker: BatchChannelTracker; + deposit: bigint; +} + +/** A payment the client has built and not yet heard back about. */ +export type PendingPayment = { + payload: Extract; + x402Version: number; +}; + +/** An in-flight payment plus the confirmed allocation to restore if it is rejected. */ +export type PendingChannel = OpenChannel & { + /** Confirmed allocation to restore if this pending request is rejected. */ + confirmed?: OpenChannel | undefined; + key: string; + operationKey: string; + amount: string; + cumulative: bigint; + payment: PendingPayment; +}; + +/** Arguments core passes to the client's payment-response hook. */ +export type PaymentResponseContext = Parameters< + NonNullable +>[0]; + +/** Channel terms the client resolved from a 402 before it builds a payment. */ +export type ResolvedTerms = { + feePayer: string; + receiverAuthorizer: string; + tokenProgram: string; + withdrawDelay: number; + memo?: string | undefined; + voucherSigner: "client" | "server"; + operator?: string | undefined; + /** Grant under which server mode was allowed; absent in client mode. */ + trust?: ResolvedServerSignedTrust | undefined; +}; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/closeAuthorization.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/closeAuthorization.ts new file mode 100644 index 0000000000..5c39c0b18b --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/closeAuthorization.ts @@ -0,0 +1,156 @@ +/** + * Server-signed authorization for an immediate cooperative close. + * + * The receiver authorizer signs the SHA-256 digest of a domain-separated + * message binding the network, program, sponsor, channel, final voucher and + * an expiry, so one signature can authorize exactly one close and nothing + * else (spec section 4.2, `CloseAuthorization`). + */ + +import { sha256 } from "@noble/hashes/sha256"; +import { + createSignableMessage, + getBase58Decoder, + getBase58Encoder, + type MessagePartialSigner, +} from "@solana/kit"; + +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../payment-channels/onchain"; +import { verifyEd25519Signature } from "../payment-channels/voucher"; +import { CLOSE_DOMAIN } from "./constants"; +import type { CloseAuthorization } from "./types"; + +/** Fields the close authorization commits to. */ +export interface CloseAuthorizationBinding { + /** CAIP-2 network identifier from `PaymentRequirements`. */ + network: string; + /** Sponsor recorded as channel `payee` and `rent_payer` (`extra.feePayer`). */ + feePayer: string; + /** Channel PDA (base58). */ + channelId: string; + /** Final voucher cumulative amount in atomic units. */ + maxClaimableAmount: bigint; + /** Final voucher expiry; `0` in this scheme. */ + voucherExpiresAt: bigint; + /** Unix seconds after which the authorization is void. */ + validBefore: number; + /** Payment-channels program id; defaults to the canonical deployment. */ + programId?: string | undefined; +} + +/** + * SHA-256 digest the receiver authorizer signs: + * + * ```text + * UTF8("x402:batch-settlement:svm:close:v1") || 0x00 || + * u16(len(network)).le || UTF8(network) || + * programId[32] || feePayer[32] || channelId[32] || + * u64(maxClaimableAmount).le || i64(voucherExpiresAt).le || i64(validBefore).le + * ``` + * + * @param binding - Fields to commit to + * @returns The 32-byte digest + */ +export function encodeCloseAuthorizationDigest(binding: CloseAuthorizationBinding): Uint8Array { + const base58 = getBase58Encoder(); + const network = new TextEncoder().encode(binding.network); + if (network.byteLength === 0 || network.byteLength > 0xffff) { + throw new Error("close authorization network must encode to 1 through 65535 bytes"); + } + const keys = [ + binding.programId ?? PAYMENT_CHANNELS_PROGRAM_ID, + binding.feePayer, + binding.channelId, + ].map(key => base58.encode(key) as Uint8Array); + if (keys.some(key => key.byteLength !== 32)) { + throw new Error("close authorization keys must decode to 32 bytes"); + } + if (binding.maxClaimableAmount < 0n || binding.maxClaimableAmount > 0xffff_ffff_ffff_ffffn) { + throw new Error("close authorization maxClaimableAmount must fit in a u64"); + } + if (!Number.isSafeInteger(binding.validBefore) || binding.validBefore <= 0) { + throw new Error("close authorization validBefore must be a positive safe integer"); + } + const message = new Uint8Array(CLOSE_DOMAIN.byteLength + 1 + 2 + network.byteLength + 96 + 24); + const view = new DataView(message.buffer); + let offset = 0; + message.set(CLOSE_DOMAIN, offset); + offset += CLOSE_DOMAIN.byteLength; + message[offset] = 0x00; + offset += 1; + view.setUint16(offset, network.byteLength, true); + offset += 2; + message.set(network, offset); + offset += network.byteLength; + for (const key of keys) { + message.set(key, offset); + offset += 32; + } + view.setBigUint64(offset, binding.maxClaimableAmount, true); + offset += 8; + view.setBigInt64(offset, binding.voucherExpiresAt, true); + offset += 8; + view.setBigInt64(offset, BigInt(binding.validBefore), true); + return sha256(message); +} + +/** + * Sign a close authorization with the server's receiver authorizer. + * + * @param authorizer - Receiver-authorizer key advertised as `extra.receiverAuthorizer` + * @param binding - Fields to commit to + * @returns The wire `CloseAuthorization` + */ +export async function signCloseAuthorization( + authorizer: MessagePartialSigner, + binding: CloseAuthorizationBinding, +): Promise { + const digest = encodeCloseAuthorizationDigest(binding); + const [signatures] = await authorizer.signMessages([createSignableMessage(digest)]); + const signature = signatures[authorizer.address]; + if (!signature) throw new Error("receiver authorizer did not return a close signature"); + return { + validBefore: binding.validBefore, + signature: getBase58Decoder().decode(signature as Uint8Array), + }; +} + +/** + * Verify a close authorization against the receiver authorizer bound to the + * channel, requiring `now < validBefore <= now + maxTimeoutSeconds`. + * + * @param authorization - The wire `CloseAuthorization` + * @param binding - Fields the signature must commit to; `validBefore` is taken from the authorization + * @param receiverAuthorizer - Trusted receiver-authorizer key (base58) + * @param maxTimeoutSeconds - Longest validity window the facilitator accepts + * @param nowSeconds - Current Unix time + * @returns Whether the authorization is valid and unexpired + */ +export async function verifyCloseAuthorization( + authorization: CloseAuthorization, + binding: Omit, + receiverAuthorizer: string, + maxTimeoutSeconds: number, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + try { + if ( + !Number.isSafeInteger(authorization.validBefore) || + authorization.validBefore <= nowSeconds || + authorization.validBefore > nowSeconds + maxTimeoutSeconds + ) { + return false; + } + const base58 = getBase58Encoder(); + return await verifyEd25519Signature({ + message: encodeCloseAuthorizationDigest({ + ...binding, + validBefore: authorization.validBefore, + }), + publicKey: base58.encode(receiverAuthorizer) as Uint8Array, + signature: base58.encode(authorization.signature) as Uint8Array, + }); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/constants.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/constants.ts new file mode 100644 index 0000000000..78d29d1ba8 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/constants.ts @@ -0,0 +1,20 @@ +/** Scheme name on the wire and in payment requirements. */ +export const BATCH_SETTLEMENT_SCHEME = "batch-settlement"; + +/** Shortest grace period a channel may advertise, in seconds (15 minutes). */ +export const MIN_WITHDRAW_DELAY = 900; + +/** Longest grace period a channel may advertise, in seconds (30 days). */ +export const MAX_WITHDRAW_DELAY = 2_592_000; + +/** Basis points that assign the whole deposit to one recipient. */ +export const FULL_SPLIT_BPS = 10_000; + +/** Voucher expiry this scheme uses: the voucher does not expire on its own. */ +export const CLIENT_VOUCHER_EXPIRES_AT = 0; + +/** Domain separator for the payer authorization a server-signed channel spends. */ +export const AUTHORIZATION_DOMAIN = new TextEncoder().encode("x402-batch-authorization-v2"); + +/** Domain separator for a receiver-authorizer close authorization. */ +export const CLOSE_DOMAIN = new TextEncoder().encode("x402:batch-settlement:svm:close:v1"); diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/errors.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/errors.ts new file mode 100644 index 0000000000..7245fde695 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/errors.ts @@ -0,0 +1,30 @@ +const PREFIX = "invalid_batch_settlement_svm_"; + +export const BatchError = { + PAYLOAD_TYPE: `${PREFIX}payload_type`, + PAYMENT_FLOW: `${PREFIX}payment_flow`, + TOKEN_PROGRAM: `${PREFIX}token_program`, + VOUCHER_SIGNATURE: `${PREFIX}voucher_signature`, + CHANNEL_ID_MISMATCH: `${PREFIX}channel_id_mismatch`, + FEE_PAYER_MISMATCH: `${PREFIX}fee_payer_mismatch`, + RECEIVER_AUTHORIZER_MISMATCH: `${PREFIX}receiver_authorizer_mismatch`, + RECEIVER_BINDING_UNAVAILABLE: `${PREFIX}receiver_binding_unavailable`, + DELEGATED_UNAUTHENTICATED: `${PREFIX}delegated_unauthenticated`, + CLOSE_AUTHORIZATION: `${PREFIX}close_authorization`, + CLOSE_AMOUNT_UNSUPPORTED: `${PREFIX}close_amount_unsupported`, + CLOSE_STATE: `${PREFIX}close_state`, + CHANNEL_CLOSING: `${PREFIX}channel_closing`, + WITHDRAW_DELAY_MISMATCH: `${PREFIX}withdraw_delay_mismatch`, + WITHDRAW_DELAY_OUT_OF_RANGE: `${PREFIX}withdraw_delay_out_of_range`, + CUMULATIVE_AMOUNT_MISMATCH: `${PREFIX}cumulative_amount_mismatch`, + CUMULATIVE_EXCEEDS_DEPOSIT: `${PREFIX}cumulative_exceeds_deposit`, + VOUCHER_EXPIRY: `${PREFIX}voucher_expiry`, + SETUP_TRANSACTION: `${PREFIX}setup_transaction`, + SETTLEMENT_SIMULATION: `${PREFIX}settlement_simulation`, + DEPOSIT_BELOW_MIN_DEPOSIT: `${PREFIX}deposit_below_min_deposit`, + CHANNEL_STATE: `${PREFIX}channel_state`, + REFUND_TRANSACTION: `${PREFIX}refund_transaction`, + PAYOUT_ATTRIBUTION_AMBIGUOUS: `${PREFIX}payout_attribution_ambiguous`, +} as const; + +export type BatchErrorReason = (typeof BatchError)[keyof typeof BatchError]; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/bindingSource.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/bindingSource.ts new file mode 100644 index 0000000000..4c650ac6c6 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/bindingSource.ts @@ -0,0 +1,343 @@ +import { isAddress } from "@solana/kit"; +import type { + FacilitatorContext, + Network, + PaymentRequirements, + SettleResponse, +} from "@x402/core/types"; + +import type { PaymentChannelStorage } from "../../payment-channels/storage"; +import type { BatchPendingSettlementStore } from "./recovery"; + +import { BatchError } from "../errors"; +import { readReceiverBindingFromOpen } from "../receiverBinding"; +import type { BatchDelegatedReceiverAuth, BatchDelegatedSettleContext } from "./delegatedAuthStore"; +import { + BatchReceiverAuthorizerConflictError, + type BatchReceiverAuthorizerStore, +} from "./receiverAuthorizerStore"; +import type { + BatchReceiverBindingHistoryReader, + BatchReceiverBindingHistorySignature, + BindingSourceConfig, +} from "./types"; +import { BINDING_HISTORY_PAGE_LIMIT } from "./constants"; + +/** Optional configuration for the batch-settlement SVM facilitator. */ +export interface BatchSvmFacilitatorConfig { + /** + * Durable record of pending signatures and completed operation outcomes, so + * retries — including after a restart — reconcile instead of rebroadcasting. + * Defaults to an in-memory store. Production deployments should supply a + * shared durable store whose TTL covers their client retry window. + */ + pendingSettlementStore?: BatchPendingSettlementStore | undefined; + /** Called before completing a payout; implementations must deduplicate by transaction. */ + onDistributionConfirmed?: ( + response: SettleResponse, + requirements: PaymentRequirements, + ) => Promise; + /** Shared, facilitator-owned lifecycle index used for rent cleanup. */ + channelStorage?: PaymentChannelStorage | undefined; + /** + * Idle window advertised as `extra.maxIdleSecs`: seconds without + * facilitator-visible lifecycle activity after which rent cleanup MAY + * abandon-close an Open channel at its settled watermark. `0` disables and + * is not advertised. Defaults to `DEFAULT_MAX_IDLE_SECS` (seven days). + */ + maxIdleSecs?: number | undefined; + maxPriorityFeeMicroLamports?: number | undefined; + maxComputeUnits?: number | undefined; + maxRequiredSignatures?: number | undefined; + /** + * Receiver authorizer each channel was opened for. Required unless + * {@link receiverBindingHistoryReader} is set. When this is the only source, + * the open is bound and read back before broadcast, and a failed write does + * not send the transaction. When a history reader is also set, a failed + * write still broadcasts. A missing row is resolved from that reader and + * written back here. + */ + receiverAuthorizerStore?: BatchReceiverAuthorizerStore | undefined; + /** + * Explicit full-history reader of the open transaction's binding memo. + * Used only when set here: the facilitator does not adopt one from the + * signer or a public RPC. Used when the store misses, and as the only + * record when no store is configured. Configuring both is allowed: the + * store is primary, a failed store write still broadcasts the open, and + * the history reader is the fallback plus the source for write-back. + */ + receiverBindingHistoryReader?: BatchReceiverBindingHistoryReader | undefined; + /** + * Opt in to facilitator-delegated close authorization. Advertised as + * `/supported` `extra.receiverAuthorizer`. The caller identity is kept only + * in {@link BatchDelegatedReceiverAuth.identityStore}; losing it fails + * closed and the client falls back to `request_close`. + */ + delegatedReceiverAuth?: BatchDelegatedReceiverAuth | undefined; +} + +/** + * Require a store or an explicit history reader, and reject a value that lacks its methods. + * + * @param config - Facilitator configuration + */ +export function assertBindingSource(config: BindingSourceConfig): void { + const store = config.receiverAuthorizerStore; + const historyReader = config.receiverBindingHistoryReader; + if (store !== undefined && !isReceiverAuthorizerStore(store)) { + throw new Error("receiverAuthorizerStore must implement bind, get, and delete"); + } + if (historyReader !== undefined && !isReceiverBindingHistoryReader(historyReader)) { + throw new Error( + "receiverBindingHistoryReader must implement getSignaturesForAddress and getTransaction", + ); + } + if (store === undefined && historyReader === undefined) { + throw new Error( + "BatchSvmScheme requires a receiverAuthorizerStore or a receiverBindingHistoryReader", + ); + } +} + +/** + * Require the delegated-auth callbacks when the option is set. + * + * @param delegated - Delegated receiver-authorizer config, when opted in + * @returns The same config, or undefined when delegation is off + */ +export function assertDelegatedReceiverAuth( + delegated: BatchDelegatedReceiverAuth | undefined, +): BatchDelegatedReceiverAuth | undefined { + if (delegated === undefined) return undefined; + if ( + !isAddress(delegated.receiverAuthorizer) || + typeof delegated.resolveCallerIdentity !== "function" + ) { + throw new Error( + "delegatedReceiverAuth requires a receiverAuthorizer address and resolveCallerIdentity", + ); + } + const store = delegated.identityStore; + if ( + !store || + typeof store.bind !== "function" || + typeof store.get !== "function" || + typeof store.delete !== "function" + ) { + throw new Error("delegatedReceiverAuth.identityStore must implement bind, get, and delete"); + } + return delegated; +} + +/** + * Receiver authorizer bound to a channel: the store, then the open + * transaction. A store hit is not re-read. A history read is written back + * when a store is configured. + * + * @param store - Binding store, when configured + * @param historyReader - Full-history open reader, when configured + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + * @returns The bound key, or undefined when neither source has it + */ +export async function readReceiverAuthorizer( + store: BatchReceiverAuthorizerStore | undefined, + historyReader: BatchReceiverBindingHistoryReader | undefined, + network: Network, + channelId: string, +): Promise { + const stored = store ? (await store.get(network, channelId))?.receiverAuthorizer : undefined; + if (stored !== undefined) return stored; + if (!historyReader) return undefined; + const fromHistory = await readBindingFromHistory(historyReader, network, channelId); + if (fromHistory === undefined) return undefined; + if (store) { + try { + await store.bind({ channelId, network, receiverAuthorizer: fromHistory }); + } catch (error) { + if (error instanceof BatchReceiverAuthorizerConflictError) { + throw new Error(`${BatchError.RECEIVER_AUTHORIZER_MISMATCH}: ${error.message}`); + } + throw error; + } + } + return fromHistory; +} + +/** + * Caller identity for a delegated open, required before broadcast. + * + * @param delegated - Delegated receiver-authorizer config, when opted in + * @param receiverAuthorizer - Key the open is binding + * @param channelId - Channel PDA + * @param payer - Channel payer + * @param requirements - Payment requirements for the open + * @param context - Facilitator extensions + * @returns The identity, or undefined when this open is not delegated + */ +export async function delegatedIdentityForOpen( + delegated: BatchDelegatedReceiverAuth | undefined, + receiverAuthorizer: string, + channelId: string, + payer: string, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + if (!delegated || receiverAuthorizer !== delegated.receiverAuthorizer) return undefined; + const identity = await resolveDelegatedIdentity(delegated, { + channelId, + facilitatorContext: context, + network: requirements.network, + payer, + step: "deposit", + }); + if (!identity) { + throw new Error( + `${BatchError.DELEGATED_UNAUTHENTICATED}: caller identity is required to open a delegated channel`, + ); + } + return identity; +} + +/** + * Whether `bound` is the key this facilitator advertises for delegated closes. + * + * @param delegated - Delegated receiver-authorizer config, when opted in + * @param bound - Receiver authorizer bound to the channel + * @returns True when a delegated close must match the stored caller identity + */ +export function isDelegatedAuthorizer( + delegated: BatchDelegatedReceiverAuth | undefined, + bound: string, +): boolean { + return delegated?.receiverAuthorizer === bound; +} + +/** + * Resolve a delegated settle's caller identity. Throws and empty results are + * unauthenticated. + * + * @param delegated - Delegated receiver-authorizer config, when opted in + * @param ctx - Settle context passed to the operator resolver + * @returns Stable identity, or undefined when the caller is unauthenticated + */ +export async function resolveDelegatedIdentity( + delegated: BatchDelegatedReceiverAuth | undefined, + ctx: BatchDelegatedSettleContext, +): Promise { + const resolve = delegated?.resolveCallerIdentity; + if (!resolve) return undefined; + try { + const identity = await resolve(ctx); + if (typeof identity !== "string" || identity.length === 0) return undefined; + return identity; + } catch { + return undefined; + } +} + +/** + * Identity recorded for a delegated channel. + * + * @param delegated - Delegated receiver-authorizer config, when opted in + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + * @returns Stored identity, or undefined when this facilitator has no row + */ +export async function storedDelegatedIdentity( + delegated: BatchDelegatedReceiverAuth | undefined, + network: Network, + channelId: string, +): Promise { + const store = delegated?.identityStore; + if (!store) return undefined; + return (await store.get(network, channelId))?.callerIdentity; +} + +/** + * Sum the still-undistributed settled amount across channels. + * + * @param channels - Settled and already-paid watermarks + * @returns Atomic units still owed to recipients + */ +export function calculateDistributionAmount( + channels: readonly { payoutWatermark: bigint; settled: bigint }[], +): bigint { + return channels.reduce((total, channel) => { + if (channel.payoutWatermark > channel.settled) { + throw new Error(`${BatchError.CHANNEL_STATE}: payout watermark exceeds settled amount`); + } + return total + channel.settled - channel.payoutWatermark; + }, 0n); +} + +/** + * Page channel signatures back to the oldest successful transaction and + * return the binding memo on its open. + * + * @param historyReader - Full-history open reader + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + * @returns The bound key, or undefined when no open carries exactly one + */ +async function readBindingFromHistory( + historyReader: BatchReceiverBindingHistoryReader, + network: Network, + channelId: string, +): Promise { + const pages: BatchReceiverBindingHistorySignature[][] = []; + let before: string | undefined; + for (;;) { + const page = await historyReader.getSignaturesForAddress(network, channelId, { + limit: BINDING_HISTORY_PAGE_LIMIT, + ...(before !== undefined ? { before } : {}), + }); + if (page.length === 0) break; + pages.push(page); + const oldest = page[page.length - 1]; + if (!oldest || page.length < BINDING_HISTORY_PAGE_LIMIT) break; + before = oldest.signature; + } + for (let pageIndex = pages.length - 1; pageIndex >= 0; pageIndex -= 1) { + const page = pages[pageIndex]; + if (!page) continue; + for (let index = page.length - 1; index >= 0; index -= 1) { + const item = page[index]; + if (!item || item.err) continue; + const wire = await historyReader.getTransaction(network, item.signature); + if (!wire) continue; + const bound = readReceiverBindingFromOpen(wire, channelId); + if (bound !== undefined) return bound; + } + } + return undefined; +} + +/** + * Whether a value can record and read receiver-authorizer bindings. + * + * @param value - Configured store + * @returns True when bind, get, and delete are functions + */ +function isReceiverAuthorizerStore(value: object): value is BatchReceiverAuthorizerStore { + const store = value as Partial; + return ( + typeof store.bind === "function" && + typeof store.get === "function" && + typeof store.delete === "function" + ); +} + +/** + * Whether a value can page channel signatures and read their transactions. + * + * @param value - Configured history reader + * @returns True when both read methods are functions + */ +function isReceiverBindingHistoryReader(value: object): value is BatchReceiverBindingHistoryReader { + const historyReader = value as Partial; + return ( + typeof historyReader.getSignaturesForAddress === "function" && + typeof historyReader.getTransaction === "function" + ); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/constants.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/constants.ts new file mode 100644 index 0000000000..f67d3a2a77 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/constants.ts @@ -0,0 +1,14 @@ +/** Four Ed25519+settle pairs fit under Solana's transaction packet limit. */ +export const MAX_CHANNELS_PER_SETTLE_TX = 4; + +/** How many times a post-broadcast channel read retries a transient RPC failure. */ +export const CHANNEL_READ_ATTEMPTS = 5; + +/** First backoff, in milliseconds, before retrying a channel read. Later attempts double it. */ +export const CHANNEL_READ_INITIAL_BACKOFF_MS = 200; + +/** Suffix on the pending-settlement key once a broadcast's postcondition has been observed. */ +export const COMPLETED_BROADCAST_SUFFIX = ":completed"; + +/** `getSignaturesForAddress` page size. RPC rejects a limit above this. */ +export const BINDING_HISTORY_PAGE_LIMIT = 1_000; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/delegatedAuthStore.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/delegatedAuthStore.ts new file mode 100644 index 0000000000..492e05ae75 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/delegatedAuthStore.ts @@ -0,0 +1,127 @@ +import type { Address } from "@solana/kit"; +import type { FacilitatorContext, Network } from "@x402/core/types"; + +/** + * Facilitator-delegated receiver authorization. + * + * Advertised as `/supported` `extra.receiverAuthorizer`. The facilitator + * binds the caller's identity at open and requires the same identity to seal + * or cooperatively refund, so those closes carry no `CloseAuthorization`. + */ +export interface BatchDelegatedReceiverAuth { + /** Advertised as `/supported` extra.receiverAuthorizer and bound into delegated channels. */ + receiverAuthorizer: Address; + identityStore: BatchDelegatedAuthStore; + resolveCallerIdentity( + ctx: BatchDelegatedSettleContext, + ): Promise | string | undefined; +} + +/** Context passed to {@link BatchDelegatedReceiverAuth.resolveCallerIdentity}. */ +export interface BatchDelegatedSettleContext { + step: "deposit" | "seal" | "refund"; + channelId: string; + network: Network; + payer: string; + facilitatorContext?: FacilitatorContext | undefined; +} + +/** + * Caller identity bound to a delegated channel at open, so a later seal or + * cooperative refund can be correlated to the same service. + * + * The identity is not onchain. A lost row fails closed: the server can no + * longer seal or cooperatively refund, and the client falls back to + * `request_close`. + */ +export interface BatchDelegatedAuthBinding { + network: Network; + channelId: string; + /** Identity `resolveCallerIdentity` returned on the open settle. */ + callerIdentity: string; +} + +/** Thrown by {@link BatchDelegatedAuthStore.bind} when a different identity owns the channel. */ +export class BatchDelegatedAuthIdentityConflictError extends Error { + /** Create an error when a channel already has a different delegated identity. */ + constructor() { + super("delegated auth binding already exists for a different identity"); + this.name = "BatchDelegatedAuthIdentityConflictError"; + } +} + +/** + * Pluggable store of delegated open/close caller-identity bindings. + * + * `bind` is keyed by `(network, channelId)` and is first-writer-wins: + * + * - no existing row → insert + * - existing row, same `callerIdentity` → success (idempotent retry after + * bind-then-broadcast-fail) + * - existing row, different `callerIdentity` → {@link BatchDelegatedAuthIdentityConflictError} + * + * There is no expiry: a channel lives until it is closed. `get` propagates + * store errors so a host can map infra failures separately from unauthenticated. + */ +export interface BatchDelegatedAuthStore { + bind(binding: BatchDelegatedAuthBinding): Promise; + get(network: Network, channelId: string): Promise; + delete(network: Network, channelId: string): Promise; +} + +/** + * In-memory {@link BatchDelegatedAuthStore}. A multi-replica facilitator must + * inject a shared implementation; a lost binding fails closed. + */ +export class InMemoryBatchDelegatedAuthStore implements BatchDelegatedAuthStore { + private readonly bindings = new Map(); + + /** + * Record the caller identity for a channel. First writer wins: a later + * `bind` with the same identity is a no-op; a different identity is an error. + * + * @param binding - Network, channel, and caller identity + */ + async bind(binding: BatchDelegatedAuthBinding): Promise { + const key = bindingKey(binding.network, binding.channelId); + const existing = this.bindings.get(key); + if (existing) { + if (existing.callerIdentity === binding.callerIdentity) return; + throw new BatchDelegatedAuthIdentityConflictError(); + } + this.bindings.set(key, { ...binding }); + } + + /** + * Look up a binding. + * + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + * @returns Stored binding, or undefined when absent + */ + async get(network: Network, channelId: string): Promise { + const binding = this.bindings.get(bindingKey(network, channelId)); + return binding ? { ...binding } : undefined; + } + + /** + * Remove a binding. + * + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + */ + async delete(network: Network, channelId: string): Promise { + this.bindings.delete(bindingKey(network, channelId)); + } +} + +/** + * Composite key so the same PDA on two networks cannot collide. + * + * @param network - CAIP-2 network + * @param channelId - Channel PDA + * @returns Store key + */ +function bindingKey(network: Network, channelId: string): string { + return `${network}:${channelId}`; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/index.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/index.ts new file mode 100644 index 0000000000..2b40314746 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/index.ts @@ -0,0 +1,53 @@ +export { BatchSvmScheme } from "./scheme"; +export { MAX_CHANNELS_PER_SETTLE_TX } from "./constants"; +export type { BatchSvmFacilitatorConfig } from "./scheme"; +export { InMemoryPaymentChannelStorage as InMemoryBatchChannelStorage } from "../../payment-channels/storage"; +export type { + PaymentChannelRecord as BatchChannelRecord, + PaymentChannelStorage as BatchChannelStorage, +} from "../../payment-channels/storage"; +export { + BatchSvmRentCleanupManager, + DEFAULT_ABANDON_GRACE_SECS, + DEFAULT_MAX_CLOSES_PER_RUN, + DEFAULT_MAX_IDLE_SECS, + DEFAULT_MAX_RECLAIMS_PER_TX, + DEFAULT_MAX_TXS_PER_RUN, + DEFAULT_MAX_TXS_PER_SIGNER, + MAX_SAFE_RECLAIMS_PER_TX, +} from "./rentCleanupManager"; +export type { + BatchSvmRentCleanupManagerConfig, + RentCleanupCloseResult, + RentCleanupOptions, + RentCleanupReclaimResult, + RentCleanupStartConfig, + RentDiscoveryOptions, + RentDiscoveryResult, +} from "./rentCleanupManager"; + +export { InMemoryBatchPendingSettlementStore } from "./recovery"; +export type { BatchPendingSettlementStore } from "./recovery"; +export { + BatchDelegatedAuthIdentityConflictError, + InMemoryBatchDelegatedAuthStore, +} from "./delegatedAuthStore"; +export type { + BatchDelegatedAuthBinding, + BatchDelegatedAuthStore, + BatchDelegatedReceiverAuth, + BatchDelegatedSettleContext, +} from "./delegatedAuthStore"; +export { + BatchReceiverAuthorizerConflictError, + InMemoryBatchReceiverAuthorizerStore, +} from "./receiverAuthorizerStore"; +export type { + BatchReceiverAuthorizerBinding, + BatchReceiverAuthorizerStore, +} from "./receiverAuthorizerStore"; +export { createReceiverBindingHistoryReader } from "./receiverBindingHistoryReader"; +export type { + BatchReceiverBindingHistoryReader, + BatchReceiverBindingHistorySignature, +} from "./types"; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverAuthorizerStore.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverAuthorizerStore.ts new file mode 100644 index 0000000000..382386e648 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverAuthorizerStore.ts @@ -0,0 +1,123 @@ +import type { Network } from "@x402/core/types"; + +import { BatchError } from "../errors"; + +/** The receiver authorizer a channel was opened for, from its binding memo. */ +export interface BatchReceiverAuthorizerBinding { + network: Network; + channelId: string; + receiverAuthorizer: string; +} + +/** Thrown by {@link BatchReceiverAuthorizerStore.bind} when a different key owns the channel. */ +export class BatchReceiverAuthorizerConflictError extends Error { + /** Create an error when a channel is already bound to a different receiver authorizer. */ + constructor() { + super("receiver authorizer binding already exists for a different key"); + this.name = "BatchReceiverAuthorizerConflictError"; + } +} + +/** + * Pluggable store of channel receiver-authorizer bindings. + * + * `bind` is keyed by `(network, channelId)` and is first-writer-wins: + * + * - no existing row → insert + * - existing row, same `receiverAuthorizer` → success (idempotent retry after + * bind-then-broadcast-fail) + * - existing row, different `receiverAuthorizer` → {@link BatchReceiverAuthorizerConflictError} + * + * The payer-signed binding memo makes a lost row reconstructable from the + * open transaction. This implementation assumes the facilitator keeps the row. + */ +export interface BatchReceiverAuthorizerStore { + bind(binding: BatchReceiverAuthorizerBinding): Promise; + get(network: Network, channelId: string): Promise; + delete(network: Network, channelId: string): Promise; +} + +/** + * In-memory {@link BatchReceiverAuthorizerStore}. A multi-replica facilitator + * should inject a shared implementation. + */ +export class InMemoryBatchReceiverAuthorizerStore implements BatchReceiverAuthorizerStore { + private readonly bindings = new Map(); + + /** + * Record the receiver authorizer for a channel. First writer wins. + * + * @param binding - Network, channel, and receiver authorizer + */ + async bind(binding: BatchReceiverAuthorizerBinding): Promise { + const key = bindingKey(binding.network, binding.channelId); + const existing = this.bindings.get(key); + if (existing) { + if (existing.receiverAuthorizer === binding.receiverAuthorizer) return; + throw new BatchReceiverAuthorizerConflictError(); + } + this.bindings.set(key, { ...binding }); + } + + /** + * Look up a binding. + * + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + * @returns Stored binding, or undefined when absent + */ + async get( + network: Network, + channelId: string, + ): Promise { + const binding = this.bindings.get(bindingKey(network, channelId)); + return binding ? { ...binding } : undefined; + } + + /** + * Remove a binding. + * + * @param network - CAIP-2 network the channel was opened on + * @param channelId - Channel PDA + */ + async delete(network: Network, channelId: string): Promise { + this.bindings.delete(bindingKey(network, channelId)); + } +} + +/** + * Require a channel's stored binding to be the advertised key. + * + * @param bound - Resolved binding, undefined when unavailable + * @param advertised - `extra.receiverAuthorizer` of the request + * @param channelId - Channel PDA, for the message + * @returns The bound key + */ +export function requireReceiverAuthorizer( + bound: string | undefined, + advertised: string, + channelId: string, +): string { + if (bound === undefined) { + throw new Error( + `${BatchError.RECEIVER_BINDING_UNAVAILABLE}: no receiver authorizer is bound to ${channelId}`, + ); + } + if (bound !== advertised) { + throw new Error( + `${BatchError.RECEIVER_AUTHORIZER_MISMATCH}: advertised key is not the channel's binding`, + ); + } + return bound; +} + +/** + * Composite key so the same PDA on two networks cannot collide. + * + * @param network - CAIP-2 network + * @param channelId - Channel PDA + * @returns Store key + */ +function bindingKey(network: Network, channelId: string): string { + return `${network}:${channelId}`; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverBindingHistoryReader.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverBindingHistoryReader.ts new file mode 100644 index 0000000000..77081033d9 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/receiverBindingHistoryReader.ts @@ -0,0 +1,75 @@ +import { + channelHistoryReads, + type PaymentChannelFacilitatorSigner, +} from "../../payment-channels/signer"; +import type { FacilitatorSvmSigner } from "../../signer"; +import type { BatchReceiverBindingHistoryReader } from "./types"; + +/** Payment-channel signer that can read confirmed transaction history. */ +type ChannelHistorySigner = { + getSignaturesForAddress: NonNullable; + getTransaction: NonNullable; +}; + +/** + * History reader backed by {@link channelHistoryReads}. + * + * @param rpcUrlByNetwork - Optional full-history RPC URL per CAIP-2 network + * @returns A reader that loads confirmed transaction wire bytes + */ +export function createReceiverBindingHistoryReader( + rpcUrlByNetwork: Partial> = {}, +): BatchReceiverBindingHistoryReader { + return historyReaderFromChannelHistory(channelHistoryReads(rpcUrlByNetwork)); +} + +/** + * Adapt history reads to the history-reader interface. + * + * @param history - Signature paging and base64 transaction reads + * @returns A reader that loads confirmed transaction wire bytes + */ +function historyReaderFromChannelHistory( + history: ChannelHistorySigner, +): BatchReceiverBindingHistoryReader { + return { + async getSignaturesForAddress(network, account, options) { + const page = await history.getSignaturesForAddress(account, network, options); + return page.map(item => ({ + err: item.err, + signature: item.signature, + })); + }, + getTransaction(network, signature) { + return history.getTransaction(signature, network); + }, + }; +} + +/** + * Adapt a signer's history reads to {@link BatchReceiverBindingHistoryReader}. + * + * `BatchSvmScheme` does not call this. Pass the result as + * `receiverBindingHistoryReader` only when that signer is the history source + * the facilitator intends to use. + * + * @param signer - Facilitator signer, possibly a {@link PaymentChannelFacilitatorSigner} + * @returns A history reader, or undefined when the signer has no history reads + */ +export function receiverBindingHistoryReaderFromSigner( + signer: FacilitatorSvmSigner, +): BatchReceiverBindingHistoryReader | undefined { + const candidate = signer as Partial< + Pick + >; + if ( + typeof candidate.getSignaturesForAddress !== "function" || + typeof candidate.getTransaction !== "function" + ) { + return undefined; + } + return historyReaderFromChannelHistory({ + getSignaturesForAddress: candidate.getSignaturesForAddress.bind(signer), + getTransaction: candidate.getTransaction.bind(signer), + }); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/recovery.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/recovery.ts new file mode 100644 index 0000000000..ba2869989c --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/recovery.ts @@ -0,0 +1,159 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { + getBase64Codec, + getCompiledTransactionMessageDecoder, + getTransactionDecoder, +} from "@solana/kit"; +import type { PendingSettlementStore } from "@x402/core/facilitator"; + +import type { FacilitatorSvmSigner } from "../../signer"; + +/** Batch recovery records must outlive an unresolved transaction. */ +export interface BatchPendingSettlementStore extends PendingSettlementStore { + /** Atomically reserve an operation before sending. Required across processes. */ + setIfAbsent?(key: string, value: string): Promise; + /** Remove only this transaction, preserving a successor reserved by another worker. */ + deleteIfEquals?(key: string, value: string): Promise; +} + +/** Reference store: process-local recovery, with no eviction of unresolved work. */ +export class InMemoryBatchPendingSettlementStore implements BatchPendingSettlementStore { + private readonly records = new Map(); + + async get(key: string): Promise { + return this.records.get(key); + } + + async set(key: string, value: string): Promise { + this.records.set(key, value); + } + + async setIfAbsent(key: string, value: string): Promise { + if (this.records.has(key)) return false; + this.records.set(key, value); + return true; + } + + async deleteIfEquals(key: string, value: string): Promise { + if (this.records.get(key) !== value) return false; + this.records.delete(key); + return true; + } + + async delete(key: string): Promise { + this.records.delete(key); + } +} + +const distributionPasses = new WeakMap< + PendingSettlementStore, + Map> +>(); + +export function distributionsForStore(store: PendingSettlementStore) { + let passes = distributionPasses.get(store); + if (!passes) distributionPasses.set(store, (passes = new Map())); + return passes; +} + +// Coalesce before any awaits, including when two schemes share a local store. +const reservations = new WeakMap>>(); + +/** + * Reserve through the store, or serialize legacy stores within this process. + * + * @param store - Replaceable recovery storage + * @param key - Operation being reserved + * @param signature - Locally derived transaction identity + * @returns Whether this caller owns the first broadcast + */ +export async function reserveBroadcast( + store: BatchPendingSettlementStore, + key: string, + signature: string, +): Promise { + if (store.setIfAbsent) return store.setIfAbsent(key, signature); + let pending = reservations.get(store); + if (!pending) reservations.set(store, (pending = new Map())); + const previous = pending.get(key) ?? Promise.resolve(false); + const next = previous + .catch(() => false) + .then(async () => { + if (await store.get(key)) return false; + await store.set(key, signature); + return true; + }); + pending.set(key, next); + try { + return await next; + } finally { + if (pending.get(key) === next) pending.delete(key); + } +} + +/** + * Thrown when a confirmed sweep cannot be attributed from balance evidence + * because the merchant recipient is also the refund or treasury beneficiary + * of a closed channel. The transaction landed; only its accounting is + * unresolved, so it is reported with its own reason rather than left pending. + */ +export class PayoutAttributionAmbiguousError extends Error { + constructor() { + super( + "closed-channel payout shares its recipient with refund or treasury; transfer attribution is ambiguous", + ); + this.name = "PayoutAttributionAmbiguousError"; + } +} + +/** + * Whether a broadcast whose confirmation was never observed can no longer + * land. Blockhash validity is checked before the final history lookup, so a + * transaction included right before its blockhash expired is still found and + * stays on the reconcile path. Anything uncertain answers `false`: a + * transaction that may have landed is never reported as failed. + * + * @param signer - Facilitator signer; needs `isBlockhashValid` and `getConfirmedTransaction` + * @param signature - Recorded signature of the broadcast + * @param network - Network it was submitted to + * @param wire - The signed bytes that were (or would have been) sent + * @returns `true` only when the blockhash is invalid and no record of the signature exists + */ +export async function broadcastExpiredWithoutLanding( + signer: Pick, + signature: string, + network: string, + wire: string | undefined, +): Promise { + if (!wire || !signer.isBlockhashValid || !signer.getConfirmedTransaction) return false; + try { + const transaction = getTransactionDecoder().decode(getBase64Codec().encode(wire)); + const { lifetimeToken } = getCompiledTransactionMessageDecoder().decode( + transaction.messageBytes, + ); + if (await signer.isBlockhashValid(lifetimeToken, network)) return false; + return (await signer.getConfirmedTransaction(signature, network)) === null; + } catch { + return false; + } +} + +/** + * Drop the signed bytes kept for rebroadcast. Best effort: the outcome they + * describe is already recorded elsewhere. + * + * @param store - Recovery storage holding the wire record + * @param network - Network the bytes were built for + * @param signature - Their locally derived signature + */ +export async function discardWire( + store: PendingSettlementStore, + network: string, + signature: string, +): Promise { + try { + await store.delete(`batch:transaction:${network}:${signature}:wire`); + } catch { + /* keep recorded outcome */ + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/rentCleanupManager.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/rentCleanupManager.ts new file mode 100644 index 0000000000..ceb516f230 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/rentCleanupManager.ts @@ -0,0 +1,70 @@ +/** + * Batch-settlement rent cleanup. The worker is + * {@link PaymentChannelRentCleanupManager}; this subclass fixes the scheme's + * abandon policy: non-expiring vouchers abandon after `maxIdleSecs` without + * facilitator-visible activity, and Closing channels are sealed once onchain + * grace elapses. + */ + +import type { Network } from "@x402/core/types"; + +import { + PaymentChannelRentCleanupManager, + type PaymentChannelRentCleanupManagerConfig, +} from "../../payment-channels/rentCleanup"; +import type { PaymentChannelStorage } from "../../payment-channels/storage"; +import type { FacilitatorSvmSigner } from "../../signer"; + +export { + DEFAULT_ABANDON_GRACE_SECS, + DEFAULT_MAX_CLOSES_PER_RUN, + DEFAULT_MAX_IDLE_SECS, + DEFAULT_MAX_RECLAIMS_PER_TX, + DEFAULT_MAX_TXS_PER_RUN, + DEFAULT_MAX_TXS_PER_SIGNER, + MAX_SAFE_RECLAIMS_PER_TX, +} from "../../payment-channels/rentCleanup"; +export type { + RentCleanupCloseResult, + RentCleanupOptions, + RentCleanupReclaimResult, + RentCleanupStartConfig, + RentDiscoveryOptions, + RentDiscoveryResult, +} from "../../payment-channels/rentCleanup"; + +/** Configuration for {@link BatchSvmRentCleanupManager}. */ +export interface BatchSvmRentCleanupManagerConfig { + signer: FacilitatorSvmSigner; + storage: PaymentChannelStorage; + network: Network; + /** + * Idle window the facilitator advertises as `extra.maxIdleSecs`. Passed into + * cleanup passes that do not override {@link RentCleanupOptions.maxIdleSecs}. + */ + maxIdleSecs?: number; +} + +/** + * Storage-driven rent cleanup worker for SVM batch settlement. + * + * Operators opt in via {@link PaymentChannelRentCleanupManager.start} or an + * external cron calling {@link PaymentChannelRentCleanupManager.cleanup}; the + * facilitator scheme never auto-starts this. + */ +export class BatchSvmRentCleanupManager extends PaymentChannelRentCleanupManager { + /** + * Create a rent cleanup manager for one network. + * + * @param config - Signer pool, channel storage, and network + */ + constructor(config: BatchSvmRentCleanupManagerConfig) { + const shared: PaymentChannelRentCleanupManagerConfig = { + ...config, + abandonPolicy: "idle", + label: "BatchSvmRentCleanupManager", + sealClosingChannels: true, + }; + super(shared); + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/responses.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/responses.ts new file mode 100644 index 0000000000..dccc96a2dd --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/responses.ts @@ -0,0 +1,371 @@ +/** + * Response shaping and error classification for the batch-settlement + * facilitator: pure functions over decoded channel accounts and settlement + * outcomes, kept apart from the scheme's request handling. + */ + +import type { Network, SettleResponse, VerifyResponse } from "@x402/core/types"; + +import { ErrSettlementPending } from "../../exact/facilitator/errors"; + +import type { Channel } from "../../payment-channels/generated/accounts/channel"; +import { + ChannelBroadcastConfirmationError, + SettlementConfirmationTimeoutError, +} from "../../payment-channels/facilitator"; +import { ChannelStatus } from "../../payment-channels/onchain"; +import { parseU64 } from "../../payment-channels/open"; +import { BatchError } from "../errors"; +import type { BatchChannelState } from "../types"; + +/** Reason reported when a channel is already mid-operation. */ +export const CHANNEL_BUSY = "duplicate_settlement"; + +/** + * The signature carried by an error that means "broadcast, outcome unknown", + * or `undefined` for anything else. + * + * @param error - The error a broadcast attempt threw + * @returns The signature already on the network, when there is one + */ +export function pendingSignatureOf(error: unknown): string | undefined { + if (error instanceof ChannelBroadcastConfirmationError) return error.signature; + if (error instanceof SettlementConfirmationTimeoutError) return String(error.signature); + return undefined; +} + +/** + * + * @param channelId + * @param channel + * @param chargedCumulativeAmount + */ +/** + * Project a decoded channel account onto the wire `channelState` snapshot. + * + * @param channelId - Channel PDA + * @param channel - Decoded channel account + * @param chargedCumulativeAmount - Server watermark to include, when known + * @returns The corrective snapshot carried in responses and 402s + */ +function snapshotChannel( + channelId: string, + channel: Channel, + chargedCumulativeAmount?: bigint, +): BatchChannelState { + const snapshot: BatchChannelState = { + channelId, + balance: channel.deposit.toString(), + totalClaimed: channel.settlement.settled.toString(), + withdrawRequestedAt: + channel.status === ChannelStatus.Closing ? Number(channel.closureStartedAt) : 0, + }; + if (chargedCumulativeAmount !== undefined) { + snapshot.chargedCumulativeAmount = chargedCumulativeAmount.toString(); + } + return snapshot; +} + +/** + * Project a decoded channel account onto the flat `/verify` response `extra` + * of the scheme spec (section 4.5): `channelId`, `balance`, `totalClaimed` + * and `withdrawRequestedAt` as siblings, not a nested snapshot. + * + * @param channelId - Channel PDA + * @param channel - Decoded channel account + * @returns The verify response `extra` + */ +export function verifiedChannelExtra( + channelId: string, + channel: Channel, +): { + channelId: string; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; +} { + const { chargedCumulativeAmount: _omitted, ...flat } = snapshotChannel(channelId, channel); + void _omitted; + return flat; +} + +/** + * + * @param channelId + * @param channel + * @param network + * @param transaction + */ +/** + * Successful `deposit` settlement response with the confirmed channel state. + * + * @param channelId - Channel PDA + * @param channel - Decoded channel account after the deposit landed + * @param network - CAIP-2 network + * @param transaction - Confirmed transaction signature + * @param amount - Amount this deposit or top-up moved into escrow, not the + * channel's running total (spec section 4.5) + * @returns The settle response + */ +export function depositResponse( + channelId: string, + channel: Channel, + network: Network, + transaction: string, + amount: bigint, +): SettleResponse { + return { + success: true, + payer: channel.payer, + transaction, + network, + amount: amount.toString(), + extra: { + channelState: snapshotChannel(channelId, channel), + }, + }; +} + +/** + * + * @param claims + * @param network + * @param transaction + */ +/** + * Successful `claim` settlement response listing every channel and the + * cumulative amount the claim advanced it to. + * + * @param claims - Channels the transaction claimed + * @param network - CAIP-2 network + * @param transaction - Confirmed transaction signature + * @returns The settle response + */ +export function claimResponse( + claims: readonly { channelId: string; cumulative: bigint }[], + network: Network, + transaction: string, +): SettleResponse { + return { + amount: "", + extra: { + accepts: claims.map(item => ({ + channelId: item.channelId, + totalClaimed: item.cumulative.toString(), + })), + }, + network, + payer: "", + success: true, + transaction, + }; +} + +/** + * + * @param channelId + * @param channel + * @param network + * @param transaction + */ +/** + * Successful `refund` settlement response with the channel state after the + * close was broadcast. + * + * @param channelId - Channel PDA + * @param channel - Decoded channel account + * @param network - CAIP-2 network + * @param transaction - Confirmed transaction signature + * @returns The settle response + */ +export function refundResponse( + channelId: string, + channel: Channel, + network: Network, + transaction: string, +): SettleResponse { + return { + success: true, + payer: channel.payer, + transaction, + network, + // Initiation only: the grace period may still be running (spec 4.5). + amount: "", + extra: { channelState: snapshotChannel(channelId, channel) }, + }; +} + +/** + * + * @param channelId + * @param payer + * @param network + * @param transaction + */ +/** + * `refund` response replayed from a durable record when the channel account + * is already gone, so only the identity the record kept is reported. + * + * @param channelId - Channel PDA + * @param payer - Channel payer + * @param network - CAIP-2 network + * @param transaction - Confirmed transaction signature + * @returns The settle response + */ +export function recoveredRefundResponse( + channelId: string, + payer: string, + network: Network, + transaction: string, +): SettleResponse { + return { + extra: { channelId }, + network, + payer, + success: true, + transaction, + }; +} + +/** + * Successful `seal` response: the final voucher was applied with + * `settle_and_seal` and the sealed `distribute` paid the receiver and returned + * the remainder to the payer in the same transaction. + * + * @param args - Final channel accounting + * @param args.channelId - Channel PDA + * @param args.payer - Channel payer + * @param args.network - CAIP-2 network + * @param args.transaction - Confirmed transaction signature + * @param args.paidToReceiver - Amount this transaction moved to `payTo` + * @param args.deposit - Channel deposit before the close + * @param args.finalSettled - Final settled watermark; `deposit - finalSettled` went back to the payer + * @returns The settle response + */ +export function sealResponse(args: { + channelId: string; + payer: string; + network: Network; + transaction: string; + paidToReceiver: bigint; + deposit: bigint; + finalSettled: bigint; +}): SettleResponse { + return { + success: true, + payer: args.payer, + transaction: args.transaction, + network: args.network, + amount: args.paidToReceiver.toString(), + extra: { + channelState: { + channelId: args.channelId, + balance: args.deposit.toString(), + totalClaimed: args.finalSettled.toString(), + withdrawRequestedAt: 0, + } satisfies BatchChannelState, + }, + }; +} + +/** + * A failed verification response. + * + * @param reason - Machine-readable reason + * @param payer - Channel payer when recoverable + * @param message - Optional human-readable detail + * @returns The verify response + */ +export function verifyFailure(reason: string, payer: string, message?: string): VerifyResponse { + return { + isValid: false, + invalidReason: reason, + ...(message ? { invalidMessage: message } : {}), + payer, + }; +} + +/** + * A failed settlement response. + * + * @param network - CAIP-2 network of the request + * @param reason - Machine-readable reason + * @param payer - Channel payer when recoverable + * @param message - Optional human-readable detail + * @returns The settle response + */ +export function settleFailure( + network: Network, + reason: string, + payer: string, + message?: string, +): SettleResponse { + return { + success: false, + network, + transaction: "", + errorReason: reason, + ...(message ? { errorMessage: message } : {}), + payer, + }; +} + +/** + * A response for work that was broadcast but whose outcome is not yet + * confirmed; the caller retries with the same request. + * + * @param network - CAIP-2 network + * @param payer - Channel payer when recoverable + * @param signature - The broadcast signature + * @param message - What is still pending + * @returns The settle response + */ +export function settlementPending( + network: Network, + payer: string, + signature: string, + message: string, +): SettleResponse { + return { + errorMessage: message, + errorReason: ErrSettlementPending, + network, + payer, + success: false, + transaction: signature, + }; +} + +/** + * + * @param error + */ +/** + * Map a thrown error onto the scheme's reason vocabulary: the busy marker, + * a known `BatchError`, or the generic `transaction_failed`. + * + * @param error - The error a settlement path threw + * @returns The reason code to report + */ +export function classifyError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(CHANNEL_BUSY)) return CHANNEL_BUSY; + if (/\b429\b/.test(message) || /\b503\b/.test(message)) return BatchError.CHANNEL_STATE; + const known = Object.values(BatchError).find(value => message.includes(value)); + return known ?? "transaction_failed"; +} + +/** + * + * @param value + */ +/** + * Parse an optional `extra.recentSlot` hint. + * + * @param value - Raw hint value, if any + * @returns The slot as a bigint, or undefined when absent + */ +export function parseOptionalSlot(value: unknown): bigint | undefined { + if (value === undefined || value === null) return undefined; + return parseU64(value as string | number | bigint, "extra.recentSlot"); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/scheme.ts new file mode 100644 index 0000000000..72ab943c05 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/scheme.ts @@ -0,0 +1,1992 @@ +/* eslint-disable jsdoc/require-jsdoc */ +import { address, type Signature } from "@solana/kit"; +import { findAssociatedTokenPda } from "@solana-program/token-2022"; +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; + +import { MEMO_PROGRAM_ADDRESS } from "../../constants"; +import { + discoverChannelsByRentPayer, + type DiscoveredChannel, +} from "../../payment-channels/discovery"; +import { getChannelDecoder, type Channel } from "../../payment-channels/generated/accounts/channel"; +import { AccountDiscriminator } from "../../payment-channels/generated/types/accountDiscriminator"; +import { + buildDistributeInstruction, + buildSettleInstructions, + ChannelStatus, + getPaymentChannelsTreasuryOwner, + type ServerInstruction, +} from "../../payment-channels/onchain"; +import { + findPaymentChannelPda, + parseU64, + verifyOpenTransaction, + verifyTopUpTransaction, +} from "../../payment-channels/open"; +import { requireTokenProgramHint } from "../../payment-channels/requirements"; +import { encodeVoucherMessageBytes, verifyVoucherSignature } from "../../payment-channels/voucher"; +import { SettlementCache } from "../../settlement-cache"; +import type { + FacilitatorAccountInfo, + FacilitatorConfirmedTransaction, + FacilitatorSigningCapabilities, + FacilitatorSvmSigner, +} from "../../signer"; +import { + broadcastOpen, + getChannelDistributionHash, + simulateOpenSettleDistribute, + submitChannelTransactionWithSigner, + ChannelSimulationError, + SettlementConfirmationTimeoutError, +} from "../../payment-channels/facilitator"; +import { + InMemoryPaymentChannelStorage, + type PaymentChannelRecord, + type PaymentChannelStorage, +} from "../../payment-channels/storage"; +import { assertMaxIdleSecs } from "../../payment-channels/rentCleanup"; +import { BatchSvmRentCleanupManager } from "./rentCleanupManager"; +import { + InMemoryBatchPendingSettlementStore, + PayoutAttributionAmbiguousError, + broadcastExpiredWithoutLanding, + discardWire, + distributionsForStore, + reserveBroadcast, + type BatchPendingSettlementStore, +} from "./recovery"; +import { + CHANNEL_BUSY, + claimResponse, + classifyError, + depositResponse, + parseOptionalSlot, + pendingSignatureOf, + recoveredRefundResponse, + refundResponse, + settleFailure, + settlementPending, + verifiedChannelExtra, + verifyFailure, +} from "./responses"; + +import { recordPendingOrTerminal, TransactionOnchainFailureError } from "../../utils"; +import { ErrSettlementPending } from "../../exact/facilitator/errors"; +import { + assertNotClosing, + prepareRefund, + type SealDependencies, + settleCooperativeRefund, + settleSeal, + validateRefund, +} from "./seal"; +import { + assertServerModeProof as checkServerModeProof, + assertServerModeRefundProof, + voucherSignerFor, +} from "./voucherMode"; +import { + assertBindingSource, + assertDelegatedReceiverAuth, + type BatchSvmFacilitatorConfig, + delegatedIdentityForOpen, + isDelegatedAuthorizer, + readReceiverAuthorizer, + resolveDelegatedIdentity, + storedDelegatedIdentity, +} from "./bindingSource"; +import { + BatchDelegatedAuthIdentityConflictError, + type BatchDelegatedReceiverAuth, +} from "./delegatedAuthStore"; +import { + BatchReceiverAuthorizerConflictError, + type BatchReceiverAuthorizerStore, +} from "./receiverAuthorizerStore"; +import { + CHANNEL_READ_ATTEMPTS, + CHANNEL_READ_INITIAL_BACKOFF_MS, + COMPLETED_BROADCAST_SUFFIX, + MAX_CHANNELS_PER_SETTLE_TX, +} from "./constants"; +import type { + BatchReceiverBindingHistoryReader, + BatchTerms, + DurableBroadcastResult, + PreparedClaim, + PreparedDistribution, + ProofAmountBound, + ValidatedDeposit, + VoucherModeBinding, +} from "./types"; +import { BatchError } from "../errors"; +import { encodeReceiverBindingMemo } from "../receiverBinding"; +import { + CLIENT_VOUCHER_EXPIRES_AT, + FULL_SPLIT_BPS, + MAX_WITHDRAW_DELAY, + MIN_WITHDRAW_DELAY, +} from "../constants"; +import { + BATCH_SETTLEMENT_SCHEME, + type BatchChannelConfig, + type BatchDepositPayload, + type BatchClaimPayload, + type BatchPayload, + type BatchRefundPayload, + type BatchSettlePayload, + type BatchVoucher, + isBatchFacilitatorPayload, + isBatchPayload, + proofOf, +} from "../types"; + +type DurablePhase = "completed" | "pending" | "broadcast"; + +type DurableObservation = { ok: true; channel: T } | { ok: false; response: SettleResponse }; + +function isTransientRpcError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b429\b/.test(message) || /\b503\b/.test(message) || /too many requests/i.test(message); +} + +export { calculateDistributionAmount, type BatchSvmFacilitatorConfig } from "./bindingSource"; + +export class BatchSvmScheme implements SchemeNetworkFacilitator { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + readonly caipFamily = "solana:*"; + private readonly channelStorage: PaymentChannelStorage; + private readonly settlementCache = new SettlementCache(); + private readonly pendingStore: BatchPendingSettlementStore; + private readonly confirmationSlots = new Map(); + private readonly distributionPasses: Map>; + private readonly maxIdleSecs: number; + private readonly receiverAuthorizers: BatchReceiverAuthorizerStore | undefined; + private readonly receiverBindingHistoryReader: BatchReceiverBindingHistoryReader | undefined; + private readonly delegatedReceiverAuth: BatchDelegatedReceiverAuth | undefined; + + constructor( + private readonly signer: FacilitatorSvmSigner, + private readonly config: BatchSvmFacilitatorConfig = {}, + ) { + if (typeof signer.getSigner !== "function") { + throw new Error("BatchSvmScheme requires getSigner on the facilitator signer"); + } + if (signer.getAddresses().length === 0) { + throw new Error("BatchSvmScheme requires at least one fee payer signer"); + } + this.channelStorage = config.channelStorage ?? new InMemoryPaymentChannelStorage(); + this.pendingStore = config.pendingSettlementStore ?? new InMemoryBatchPendingSettlementStore(); + this.distributionPasses = distributionsForStore(this.pendingStore); + this.maxIdleSecs = assertMaxIdleSecs(config.maxIdleSecs); + assertBindingSource({ + receiverAuthorizerStore: config.receiverAuthorizerStore, + receiverBindingHistoryReader: config.receiverBindingHistoryReader, + }); + this.receiverAuthorizers = config.receiverAuthorizerStore; + this.receiverBindingHistoryReader = config.receiverBindingHistoryReader; + this.delegatedReceiverAuth = assertDelegatedReceiverAuth(config.delegatedReceiverAuth); + } + + getExtra(_: Network): Record { + const addresses = this.signer.getAddresses(); + return { + feePayer: addresses[Math.floor(Math.random() * addresses.length)], + // Servers copy the idle window into the 402: it is how long they have + // to claim before an idle channel is closed at its onchain watermark. + ...(this.maxIdleSecs > 0 ? { maxIdleSecs: this.maxIdleSecs } : {}), + ...(this.delegatedReceiverAuth + ? { receiverAuthorizer: this.delegatedReceiverAuth.receiverAuthorizer } + : {}), + }; + } + + getSigners(_: string): string[] { + return [...this.signer.getAddresses()]; + } + + getChannelStorage(): PaymentChannelStorage { + return this.channelStorage; + } + + createRentCleanupManager(network: Network): BatchSvmRentCleanupManager { + return new BatchSvmRentCleanupManager({ + maxIdleSecs: this.maxIdleSecs, + network, + signer: this.signer, + storage: { + get: channelId => this.channelStorage.get(channelId), + list: () => this.channelStorage.list(), + upsert: record => this.channelStorage.upsert(record), + delete: async channelId => { + await this.channelStorage.delete(channelId); + await this.receiverAuthorizers?.delete(network, channelId); + await this.delegatedReceiverAuth?.identityStore.delete(network, channelId); + }, + }, + }); + } + + /** + * Rebuild the facilitator's onchain lifecycle view after local index loss. + * + * @param network - Network to scan + * @returns Canonical channels sponsored by any configured fee payer + */ + async discoverChannels(network: Network): Promise { + const channels = await Promise.all( + this.signer + .getAddresses() + .map(rentPayer => discoverChannelsByRentPayer(this.signer, network, rentPayer)), + ); + return [...new Map(channels.flat().map(item => [item.channelId, item])).values()]; + } + + async verify( + payment: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + const payload = payment.payload; + if (!isBatchPayload(payload)) return verifyFailure(BatchError.PAYLOAD_TYPE, ""); + if ( + payment.accepted.scheme !== BATCH_SETTLEMENT_SCHEME || + requirements.scheme !== BATCH_SETTLEMENT_SCHEME + ) { + return verifyFailure("unsupported_scheme", payload.channelConfig.payer); + } + if (payment.accepted.network !== requirements.network) { + return verifyFailure("network_mismatch", payload.channelConfig.payer); + } + + try { + switch (payload.type) { + case "deposit": { + const validated = await this.validateDeposit(payload, requirements, "exact"); + return { + isValid: true, + payer: payload.channelConfig.payer, + extra: { channelId: validated.channelId }, + }; + } + case "voucher": { + const terms = await this.resolveTerms( + payload.channelConfig, + requirements, + "requirements", + ); + const channelId = await this.deriveChannelId(payload.channelConfig, terms.feePayer); + if (payload.voucher.channelId !== channelId) { + return verifyFailure(BatchError.CHANNEL_ID_MISMATCH, payload.channelConfig.payer); + } + const channel = await this.validateVoucherOnly(payload, requirements, terms, channelId); + return { + isValid: true, + payer: payload.channelConfig.payer, + extra: verifiedChannelExtra(channelId, channel), + }; + } + case "authorization": { + const terms = await this.resolveTerms( + payload.channelConfig, + requirements, + "requirements", + ); + const channelId = await this.deriveChannelId(payload.channelConfig, terms.feePayer); + await this.assertServerModeProof(payload, channelId, requirements, "exact"); + const channel = await this.fetchChannel(requirements.network, channelId); + assertNotClosing(channel, channelId); + this.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ]); + const ceiling = parseU64(requirements.amount, "amount"); + if (ceiling > channel.deposit) { + throw new Error(BatchError.CUMULATIVE_EXCEEDS_DEPOSIT); + } + return { + isValid: true, + payer: payload.channelConfig.payer, + extra: verifiedChannelExtra(channelId, channel), + }; + } + case "refund": { + if ("amount" in payload) { + throw new Error( + `${BatchError.CLOSE_AMOUNT_UNSUPPORTED}: refund returns the full unused escrow`, + ); + } + const terms = await this.resolveTerms( + payload.channelConfig, + requirements, + "requirements", + ); + const channelId = await this.deriveChannelId(payload.channelConfig, terms.feePayer); + const voucherSigner = voucherSignerFor( + payload.channelConfig, + requirements.extra ?? {}, + "payload", + ); + if (voucherSigner === "server" && payload.voucher === undefined) { + await assertServerModeRefundProof(payload, channelId); + const channel = await this.fetchChannel(requirements.network, channelId); + assertNotClosing(channel, channelId); + this.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ChannelStatus.Closing, + ]); + return { + isValid: true, + payer: payload.channelConfig.payer, + extra: verifiedChannelExtra(channelId, channel), + }; + } + if (voucherSigner === "server" && payload.voucher !== undefined) { + throw new Error(`${BatchError.VOUCHER_SIGNATURE}: invalid payer proof`); + } + const deps = this.sealDependencies(); + const validated = await validateRefund(deps, payload, requirements, this.config); + return { + isValid: true, + payer: validated.channel.payer, + extra: verifiedChannelExtra(validated.channelId, validated.channel), + }; + } + } + } catch (error) { + return verifyFailure( + classifyError(error), + payload.channelConfig.payer, + error instanceof Error ? error.message : String(error), + ); + } + } + + async settle( + payment: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const payload = payment.payload; + if (!isBatchFacilitatorPayload(payload)) { + return settleFailure(payment.accepted.network, BatchError.PAYLOAD_TYPE, ""); + } + try { + switch (payload.type) { + case "deposit": + return await this.settleDeposit(payment, payload, requirements, context); + case "voucher": + return await this.settleVoucher(payment, payload, requirements); + case "authorization": + return await this.settleVoucher(payment, payload, requirements); + case "refund": + return await this.settleRefund(payment, payload, requirements, context); + case "claim": + return await this.settleClaims(payment, payload, requirements); + case "settle": + return await this.settleDistributions(payment, payload, requirements); + case "seal": + return await settleSeal( + this.sealDependencies(), + payment, + payload, + requirements, + "seal", + context, + ); + } + } catch (error) { + return settleFailure( + payment.accepted.network, + classifyError(error), + "channelConfig" in payload ? payload.channelConfig.payer : "", + error instanceof Error ? error.message : String(error), + ); + } + } + + async settleClaims( + payment: PaymentPayload, + payload: BatchClaimPayload, + requirements: PaymentRequirements, + ): Promise { + const prepared: PreparedClaim[] = []; + for (const claim of payload.claims) { + const terms = await this.resolveTerms(claim.channelConfig, requirements, "payload"); + const channelId = await this.deriveChannelId(claim.channelConfig, terms.feePayer); + if (channelId !== claim.channelId || channelId !== claim.voucher.channelId) { + throw new Error(BatchError.CHANNEL_ID_MISMATCH); + } + const cumulative = await this.verifySignedVoucher( + channelId, + claim.voucher, + claim.channelConfig.payerAuthorizer, + ); + prepared.push({ + claim, + channelId, + cumulative, + expiresAt: claim.voucher.expiresAt, + feePayer: terms.feePayer, + payTo: requirements.payTo, + tokenProgram: terms.tokenProgram, + terms, + }); + } + const feePayer = prepared[0]?.feePayer; + if (!feePayer || prepared.some(item => item.feePayer !== feePayer)) { + throw new Error(BatchError.FEE_PAYER_MISMATCH); + } + // Keyed by exactly what this batch advances, so a retry of the same claim + // reconciles while a different one proceeds. + const claimKey = `batch:claim:${requirements.network}:${prepared + .map(item => `${item.channelId}:${item.cumulative}`) + .sort() + .join(",")}`; + const payer = prepared[0]?.claim.channelConfig.payer ?? ""; + const instructions: ServerInstruction[] = []; + const settled = await this.settleDurably({ + key: claimKey, + network: requirements.network, + payer, + onCompleted: async signature => claimResponse(prepared, requirements.network, signature), + beforePending: async () => { + // A completed replay must not re-register a channel already reclaimed by cleanup. + await Promise.all( + prepared.map(item => + this.trackChannel({ + channelId: item.channelId, + expiresAt: item.expiresAt, + network: requirements.network, + payTo: item.payTo, + tokenProgram: item.tokenProgram, + }), + ), + ); + }, + beforeSend: async () => { + const channels = await Promise.all( + prepared.map(item => this.fetchChannel(requirements.network, item.channelId)), + ); + for (let index = 0; index < prepared.length; index += 1) { + const item = prepared[index]!; + const channel = channels[index]!; + assertNotClosing(channel, item.channelId); + this.assertClaimChannel(channel, item.claim.channelConfig, item.terms, requirements, [ + ChannelStatus.Open, + ]); + if (item.cumulative <= channel.settlement.settled || item.cumulative > channel.deposit) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + instructions.push( + ...buildSettleInstructions({ + channelId: item.channelId, + voucher: { + authorizedSigner: item.claim.channelConfig.payerAuthorizer, + cumulativeAmount: item.cumulative, + expiresAt: BigInt(item.expiresAt), + signatureBase58: item.claim.voucher.signature, + }, + }), + ); + } + if (this.settlementCache.isDuplicate(claimKey)) { + return settleFailure(payment.accepted.network, CHANNEL_BUSY, payer); + } + }, + broadcast: () => + this.submitRedemption(feePayer, requirements.network, instructions, claimKey, payer), + onReplay: signature => claimResponse(prepared, requirements.network, signature), + postcondition: async signature => { + const confirmed = await this.fetchChannelsUntil( + requirements.network, + prepared.map(item => item.channelId), + channels => + channels.every( + (channel, index) => + channel !== undefined && channel.settlement.settled >= prepared[index]!.cumulative, + ), + ); + if (!confirmed) { + return { + ok: false as const, + response: settlementPending( + requirements.network, + payer, + signature, + "claim confirmed but its channel watermark is not visible yet", + ), + }; + } + this.assertRecoveredClaims(confirmed, prepared, requirements); + return { ok: true as const, channel: confirmed }; + }, + }); + if (!("channel" in settled)) return settled; + return claimResponse(prepared, requirements.network, settled.signature); + } + + async settleDistributions( + payment: PaymentPayload, + payload: BatchSettlePayload, + requirements: PaymentRequirements, + ): Promise { + if ( + payload.channels.length === 0 || + payload.channels.length > MAX_CHANNELS_PER_SETTLE_TX || + new Set(payload.channels.map(item => item.channelId)).size !== payload.channels.length + ) { + throw new Error(`${BatchError.PAYLOAD_TYPE}: invalid channel batch`); + } + // This identifies a sweep queue, not an immutable merchant request. A later + // call may sweep new earnings after the preceding transaction is resolved. + const key = `batch:distribute:${requirements.network}:${requirements.asset}:${requirements.payTo}:${payload.channels + .map(item => item.channelId) + .sort() + .join(",")}`; + const active = this.distributionPasses.get(key); + if (active) return active; + const pass = this.distributeCurrent(payment, payload, requirements, key); + this.distributionPasses.set(key, pass); + try { + return await pass; + } finally { + if (this.distributionPasses.get(key) === pass) this.distributionPasses.delete(key); + } + } + + private async distributeCurrent( + payment: PaymentPayload, + payload: BatchSettlePayload, + requirements: PaymentRequirements, + key: string, + ): Promise { + const prepared: PreparedDistribution[] = []; + for (const entry of payload.channels) { + const terms = await this.resolveTerms(entry.channelConfig, requirements, "payload"); + const channelId = await this.deriveChannelId(entry.channelConfig, terms.feePayer); + if (channelId !== entry.channelId) throw new Error(BatchError.CHANNEL_ID_MISMATCH); + prepared.push({ + channelConfig: entry.channelConfig, + channelId, + feePayer: terms.feePayer, + terms, + }); + } + const feePayer = prepared[0]!.feePayer; + if (prepared.some(item => item.feePayer !== feePayer)) + throw new Error(BatchError.FEE_PAYER_MISMATCH); + const pending = await this.pendingStore.get(key); + if (pending) { + const recovered = await this.reconcileBroadcast(key, pending, requirements.network, ""); + if (!recovered.ok) return recovered.response; + return this.finishDistribution(key, recovered.signature, prepared, requirements); + } + const previous = await this.pendingStore.get(`${key}:result`); + const previousSlot = await this.pendingStore.get(`${key}:slot`); + if (previousSlot) this.rememberSlot(requirements.network, BigInt(previousSlot)); + const instructions: ServerInstruction[] = []; + for (const item of prepared) { + const channel = previous + ? await this.readChannel(requirements.network, item.channelId) + : await this.fetchChannel(requirements.network, item.channelId); + if (!channel && previous) continue; // The preceding payout may have closed it. + if (!channel) throw new Error(BatchError.CHANNEL_STATE); + this.assertClaimChannel(channel, item.channelConfig, item.terms, requirements, [ + ChannelStatus.Open, + ChannelStatus.Sealed, + ChannelStatus.Distributed, + ]); + if ( + channel.status === ChannelStatus.Distributed || + (channel.status === ChannelStatus.Open && + channel.settlement.payoutWatermark === channel.settlement.settled) + ) + continue; + instructions.push( + await this.distributeInstruction(item.channelId, channel, item.terms, requirements), + ); + } + if (instructions.length === 0) { + if (previous) return JSON.parse(previous) as SettleResponse; + // State alone cannot identify a previous payment or its amount. + return settleFailure(payment.accepted.network, BatchError.CUMULATIVE_AMOUNT_MISMATCH, ""); + } + // Different sweeps can share a recent blockhash. Give each new signed + // transaction its own identity; recovery always reuses these exact bytes. + instructions.push({ + programAddress: address(MEMO_PROGRAM_ADDRESS), + accounts: [], + data: new TextEncoder().encode(`x402:batch:${crypto.randomUUID()}`), + }); + await this.pendingStore.delete(this.completedBroadcastKey(key)); + const submitted = await this.submitRedemption( + feePayer, + requirements.network, + instructions, + key, + "", + ); + if (!submitted.ok) return submitted.response; + return this.finishDistribution(key, submitted.signature, prepared, requirements); + } + + private async finishDistribution( + key: string, + signature: string, + prepared: PreparedDistribution[], + requirements: PaymentRequirements, + ): Promise { + try { + // A cached result contains the actual transfer, including on its first + // successful recovery. Replaying an HTTP response does not change it. + const cached = await this.pendingStore.get( + `batch:transaction:${requirements.network}:${signature}:result`, + ); + let response = cached ? (JSON.parse(cached) as SettleResponse) : undefined; + if (!response) { + if (!this.signer.getConfirmedTransaction) + throw new Error("getConfirmedTransaction is required for payout accounting"); + let evidence: FacilitatorConfirmedTransaction | null | undefined; + for (let attempt = 0; attempt < CHANNEL_READ_ATTEMPTS; attempt += 1) { + try { + evidence = await this.signer.getConfirmedTransaction(signature, requirements.network); + } catch { + /* An unavailable history read is pending, never zero paid. */ + } + if (evidence?.meta && evidence.meta.preTokenBalances && evidence.meta.postTokenBalances) + break; + if (attempt + 1 < CHANNEL_READ_ATTEMPTS) await this.waitForChannelRead(attempt); + } + if (!evidence?.meta?.preTokenBalances || !evidence.meta.postTokenBalances) + throw new Error("payout transaction metadata is not visible yet"); + if (evidence.meta.err !== null) + throw new TransactionOnchainFailureError("distribution transaction failed onchain"); + this.rememberSlot(requirements.network, BigInt(evidence.slot)); + await this.pendingStore.set(`${key}:slot`, String(evidence.slot)); + const keys = evidence.transaction.message.accountKeys.map(item => + typeof item === "string" ? item : item.pubkey, + ); + const [recipient] = await findAssociatedTokenPda({ + mint: address(requirements.asset), + owner: address(requirements.payTo), + tokenProgram: address(prepared[0]!.terms.tokenProgram), + }); + const recipientIndex = keys.indexOf(recipient); + if (recipientIndex < 0) + throw new Error("recipient is absent from distribution transaction"); + // A sealed payout can also refund the payer or sweep treasury funds. + // Balance evidence cannot separate those legs when beneficiaries alias. + const swept: PreparedDistribution[] = []; + for (const item of prepared) { + const [escrow] = await findAssociatedTokenPda({ + mint: address(requirements.asset), + owner: address(item.channelId), + tokenProgram: address(item.terms.tokenProgram), + }); + const escrowIndex = keys.indexOf(escrow); + if (escrowIndex < 0) continue; // Already paid channel omitted from this sweep. + swept.push(item); + const closed = !evidence.meta.postTokenBalances.some( + balance => balance.accountIndex === escrowIndex, + ); + if ( + closed && + (item.channelConfig.payer === requirements.payTo || + getPaymentChannelsTreasuryOwner(requirements.network) === requirements.payTo) + ) { + throw new PayoutAttributionAmbiguousError(); + } + } + const balance = (balances: NonNullable) => { + const entry = balances.find(item => item.accountIndex === recipientIndex); + if (!entry) return 0n; + if (entry.mint !== requirements.asset || entry.owner !== requirements.payTo) + throw new Error("payout recipient balance mismatch"); + return parseU64(entry.uiTokenAmount.amount, "payout balance"); + }; + const amount = + balance(evidence.meta.postTokenBalances) - balance(evidence.meta.preTokenBalances); + if (amount < 0n) throw new Error("negative recipient payout"); + response = { + success: true, + network: requirements.network, + payer: "", + transaction: signature, + amount: amount.toString(), + extra: { channels: prepared.map(item => item.channelId) }, + }; + await this.pendingStore.set( + `batch:transaction:${requirements.network}:${signature}:result`, + JSON.stringify(response), + ); + // A confirmed `settle` is facilitator-visible lifecycle activity for + // every channel it paid (spec Phase 4), so it resets the idle clock + // the abandon-close runs on. A replayed cached result is not new + // activity and is left alone. + await Promise.all( + swept.map(item => + this.trackChannel({ + channelId: item.channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + network: requirements.network, + payTo: requirements.payTo, + tokenProgram: item.terms.tokenProgram, + }), + ), + ); + } + await this.config.onDistributionConfirmed?.(response, requirements); + await this.pendingStore.set(`${key}:result`, JSON.stringify(response)); + await this.completeBroadcast(key, signature, requirements.network); + return response; + } catch (error) { + if (error instanceof PayoutAttributionAmbiguousError) { + // The sweep landed, so holding the queue pending would only block + // every later sweep of these channels. Release it and answer with an + // explicit reason an operator can reconcile by signature; the + // recording callback is skipped because no amount can be attributed. + const response: SettleResponse = { + errorMessage: error.message, + errorReason: BatchError.PAYOUT_ATTRIBUTION_AMBIGUOUS, + network: requirements.network, + payer: "", + success: false, + transaction: signature, + }; + await this.pendingStore.set(`${key}:result`, JSON.stringify(response)); + await this.completeBroadcast(key, signature, requirements.network); + return response; + } + return settlementPending(requirements.network, "", signature, String(error)); + } + } + + private async validateDeposit( + payload: BatchDepositPayload, + requirements: PaymentRequirements, + proofBound: ProofAmountBound, + ): Promise { + const terms = await this.resolveTerms(payload.channelConfig, requirements, "requirements"); + const deposit = parseU64(payload.deposit.amount, "deposit.amount"); + const charge = parseU64(requirements.amount, "amount"); + const channelId = await this.deriveChannelId(payload.channelConfig, terms.feePayer); + const proof = proofOf(payload); + let proofAmount: bigint; + switch (proof.signer) { + case "client": { + // Parse before the channel check: a non-numeric amount fails first. + proofAmount = parseU64(proof.voucher.maxClaimableAmount, "maxClaimableAmount"); + if (proof.voucher.channelId !== channelId) { + throw new Error(`${BatchError.CHANNEL_ID_MISMATCH}: voucher channel mismatch`); + } + const voucherValid = await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: proofAmount, + expiresAt: BigInt(proof.voucher.expiresAt), + }), + signatureBase58: proof.voucher.signature, + signerBase58: payload.channelConfig.payerAuthorizer, + }); + if (!voucherValid) throw new Error(`${BatchError.VOUCHER_SIGNATURE}: invalid voucher`); + this.assertExpiry(proof.voucher.expiresAt); + break; + } + case "server": { + proofAmount = charge; + await this.assertServerModeProof(payload, channelId, requirements, proofBound); + break; + } + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + const existing = await this.readChannel(requirements.network, channelId); + if (existing) { + this.assertClaimChannel(existing, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ]); + const expectedDeposit = existing.deposit + deposit; + switch (proof.signer) { + case "server": + if (charge > expectedDeposit) { + throw new Error( + `${BatchError.CUMULATIVE_AMOUNT_MISMATCH}: charge exceeds topped-up ceiling`, + ); + } + break; + case "client": + if (proofAmount < charge || proofAmount > expectedDeposit) { + throw new Error( + `${BatchError.CUMULATIVE_AMOUNT_MISMATCH}: voucher exceeds topped-up ceiling`, + ); + } + break; + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + await verifyTopUpTransaction(payload.deposit.transaction, { + amount: deposit, + channelId, + feePayer: terms.feePayer, + from: payload.channelConfig.payer, + maxComputeUnits: this.config.maxComputeUnits, + maxPriorityFeeMicroLamports: this.config.maxPriorityFeeMicroLamports, + memo: terms.memo, + mint: requirements.asset, + tokenProgram: terms.tokenProgram, + }); + await this.assertSettlementAccounts( + requirements, + payload.channelConfig.payer, + terms.tokenProgram, + ); + return { + channelId, + deposit, + expectedDeposit, + isTopUp: true, + payload, + proof, + proofAmount, + terms, + }; + } + switch (proof.signer) { + case "server": + if (charge > deposit) { + throw new Error(`${BatchError.CUMULATIVE_AMOUNT_MISMATCH}: invalid first voucher amount`); + } + break; + case "client": + if (proofAmount !== charge || charge > deposit) { + throw new Error(`${BatchError.CUMULATIVE_AMOUNT_MISMATCH}: invalid first voucher amount`); + } + break; + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + const open = await verifyOpenTransaction(payload.deposit.transaction, { + authorizedSigner: payload.channelConfig.payerAuthorizer, + expectedBindingMemo: encodeReceiverBindingMemo(terms.receiverAuthorizer), + feePayer: terms.feePayer, + from: payload.channelConfig.payer, + maxCap: deposit, + maxComputeUnits: this.config.maxComputeUnits, + maxPriorityFeeMicroLamports: this.config.maxPriorityFeeMicroLamports, + maxRequiredSignatures: this.config.maxRequiredSignatures, + memo: terms.memo, + mint: requirements.asset, + openSlot: BigInt(payload.channelConfig.openSlot), + payee: terms.feePayer, + recentSlot: parseOptionalSlot(requirements.extra?.recentSlot), + recipients: [{ bps: FULL_SPLIT_BPS, recipient: requirements.payTo }], + tokenProgram: terms.tokenProgram, + withdrawDelay: terms.withdrawDelay, + }); + if (open.channelId !== channelId) { + throw new Error(`${BatchError.CHANNEL_ID_MISMATCH}: setup transaction channel mismatch`); + } + await this.assertSettlementAccounts( + requirements, + payload.channelConfig.payer, + terms.tokenProgram, + ); + return { + channelId, + deposit, + expectedDeposit: deposit, + isTopUp: false, + payload, + proof, + proofAmount, + terms, + }; + } + + private async settleDeposit( + payment: PaymentPayload, + payload: BatchDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const validated = await this.validateDeposit(payload, requirements, "ceiling"); + const { channelId, terms } = validated; + // Serialize opens by channel so two distinct signed setup transactions + // cannot race for the same PDA. Top-ups remain transaction-scoped because + // a channel can legitimately receive several of them within the cache TTL. + const key = validated.isTopUp + ? `batch:topup:${requirements.network}:${payload.deposit.transaction}` + : `batch:deposit:${requirements.network}:${channelId}`; + if ((await this.readChannel(requirements.network, channelId)) && !validated.isTopUp) { + const existing = await this.fetchChannel(requirements.network, channelId); + this.assertDepositChannel(existing, validated, requirements); + return depositResponse(channelId, existing, requirements.network, "", validated.deposit); + } + if (this.settlementCache.isDuplicate(key)) { + return settleFailure( + payment.accepted.network, + "duplicate_settlement", + payload.channelConfig.payer, + ); + } + try { + if (validated.isTopUp) { + // Simulated unsigned: `sigVerify` is off, so the fee payer's signature + // adds nothing here, and not asking for it keeps simulation portable + // across signer backends that will not sign the same bytes twice. + await this.signer.simulateTransaction(payload.deposit.transaction, requirements.network); + } else { + // Simulates the open/settle/distribute chain through the facilitator + // signer's own RPC, like every other read and broadcast here. + await simulateOpenSettleDistribute( + terms.feePayerSigner, + this.signer, + requirements.network, + { + channel: { + channelId, + mint: requirements.asset, + network: requirements.network, + payee: terms.feePayer, + payer: payload.channelConfig.payer, + rentPayer: terms.feePayer, + splits: [{ bps: FULL_SPLIT_BPS, recipient: requirements.payTo }], + tokenProgram: terms.tokenProgram, + }, + openTransactionBase64: payload.deposit.transaction, + }, + ); + } + } catch (error) { + this.settlementCache.delete(key); + if ( + error instanceof Error && + error.message.startsWith(`${BatchError.SETTLEMENT_SIMULATION}:`) + ) { + throw error; + } + throw new Error(`${BatchError.SETTLEMENT_SIMULATION}: ${String(error)}`); + } + try { + await this.trackChannel({ + channelId, + expiresAt: payload.voucher?.expiresAt ?? CLIENT_VOUCHER_EXPIRES_AT, + network: requirements.network, + payTo: requirements.payTo, + tokenProgram: terms.tokenProgram, + }); + if (!validated.isTopUp) { + const callerIdentity = await delegatedIdentityForOpen( + this.delegatedReceiverAuth, + terms.receiverAuthorizer, + channelId, + payload.channelConfig.payer, + requirements, + context, + ); + await this.bindReceiverAuthorizer( + channelId, + requirements.network, + terms.receiverAuthorizer, + ); + if (callerIdentity !== undefined && this.delegatedReceiverAuth) { + await this.delegatedReceiverAuth.identityStore.bind({ + callerIdentity, + channelId, + network: requirements.network, + }); + } + } + } catch (error) { + // No transaction has been broadcast. Release the channel lock so a + // caller can safely retry once durable indexing is healthy again. + this.settlementCache.delete(key); + if (error instanceof BatchReceiverAuthorizerConflictError) { + throw new Error(`${BatchError.RECEIVER_AUTHORIZER_MISMATCH}: ${error.message}`); + } + if (error instanceof BatchDelegatedAuthIdentityConflictError) { + throw new Error(`${BatchError.DELEGATED_UNAUTHENTICATED}: ${error.message}`); + } + throw error; + } + const settled = await this.settleDurably({ + key, + network: requirements.network, + payer: payload.channelConfig.payer, + send: async onBroadcast => { + try { + return await broadcastOpen( + this.submissionSigner(), + address(terms.feePayer), + requirements.network, + payload.deposit.transaction, + undefined, + onBroadcast, + ); + } catch (error) { + // Only a transaction that never reached the network frees the + // duplicate lock; one already broadcast is reconciled, not resent. + if (pendingSignatureOf(error) === undefined) this.settlementCache.delete(key); + throw error; + } + }, + postcondition: async () => { + const channel = await this.fetchChannel(requirements.network, channelId); + this.assertDepositChannel(channel, validated, requirements); + return { ok: true as const, channel }; + }, + }); + if (!("channel" in settled)) return settled; + return depositResponse( + channelId, + settled.channel, + requirements.network, + settled.signature, + validated.deposit, + ); + } + + private async assertSettlementAccounts( + requirements: PaymentRequirements, + payer: string, + tokenProgram: string, + ): Promise { + if (typeof this.signer.getAccountInfo !== "function") { + throw new Error( + "BatchSvmScheme requires getAccountInfo on the facilitator signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } + const mint = address(requirements.asset); + const tokenProgramAddress = address(tokenProgram); + const required = [ + { label: "payer", owner: address(payer) }, + { label: "recipient", owner: address(requirements.payTo) }, + { + label: "payment-channel treasury", + owner: getPaymentChannelsTreasuryOwner(requirements.network), + }, + ] as const; + for (const { label, owner } of required) { + const [ata] = await findAssociatedTokenPda({ + mint, + owner, + tokenProgram: tokenProgramAddress, + }); + const account = await this.signer.getAccountInfo(ata, requirements.network, { + commitment: "confirmed", + encoding: "base64", + }); + if (!account) { + throw new Error(`${BatchError.SETTLEMENT_SIMULATION}: missing ${label} ATA: ${ata}`); + } + if (account.owner.toString() !== tokenProgram) { + throw new Error( + `${BatchError.SETTLEMENT_SIMULATION}: ${label} ATA is not owned by ${tokenProgram}: ${ata}`, + ); + } + } + } + + private async settleVoucher( + payment: PaymentPayload, + payload: Extract, + requirements: PaymentRequirements, + ): Promise { + void requirements; + return settleFailure( + payment.accepted.network, + BatchError.PAYLOAD_TYPE, + payload.channelConfig.payer, + ); + } + + private async validateVoucherOnly( + payload: Extract, + requirements: PaymentRequirements, + terms: BatchTerms, + channelId: string, + ): Promise { + const cumulative = await this.verifySignedVoucher( + channelId, + payload.voucher, + payload.channelConfig.payerAuthorizer, + ); + const channel = await this.fetchChannel(requirements.network, channelId); + assertNotClosing(channel, channelId); + this.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ]); + if (cumulative > channel.deposit) throw new Error(BatchError.CUMULATIVE_EXCEEDS_DEPOSIT); + return channel; + } + + private async settleRefund( + payment: PaymentPayload, + payload: BatchRefundPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const deps = this.sealDependencies(); + const prepared = await prepareRefund(deps, payload, requirements, this.config, context); + const { channelId, requestClose, terms } = prepared; + if (requestClose === undefined) { + return settleCooperativeRefund(deps, payment, payload, requirements, prepared, context); + } + const key = `batch:refund:${requirements.network}:${channelId}:${requestClose}`; + const payer = payload.channelConfig.payer; + const closed: readonly ChannelStatus[] = [ + ChannelStatus.Closing, + ChannelStatus.Sealed, + ChannelStatus.Distributed, + ]; + const settled = await this.settleDurably({ + key, + network: requirements.network, + payer, + onCompleted: async signature => { + const observed = await this.fetchChannelUntil( + requirements.network, + channelId, + channel => channel === undefined || closed.includes(channel.status), + ); + if (observed) { + this.assertClaimChannel(observed, payload.channelConfig, terms, requirements, closed); + return refundResponse(channelId, observed, requirements.network, signature); + } + return recoveredRefundResponse(channelId, payer, requirements.network, signature); + }, + beforeSend: async () => { + const channel = await this.fetchChannel(requirements.network, channelId); + this.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ChannelStatus.Closing, + ]); + if (channel.status === ChannelStatus.Closing) { + return refundResponse(channelId, channel, requirements.network, ""); + } + if (this.settlementCache.isDuplicate(key)) { + return settleFailure(payment.accepted.network, "duplicate_settlement", channel.payer); + } + await this.trackChannel({ + channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + network: requirements.network, + payTo: requirements.payTo, + tokenProgram: terms.tokenProgram, + }); + }, + send: async onBroadcast => { + try { + // Simulated unsigned: the fee payer's signature is not what the + // program checks here, and leaving it off keeps simulation portable + // across signer backends that will not sign twice. + await this.signer.simulateTransaction(requestClose, requirements.network); + return await broadcastOpen( + this.submissionSigner(), + address(terms.feePayer), + requirements.network, + requestClose, + undefined, + onBroadcast, + ); + } catch (error) { + if (pendingSignatureOf(error) === undefined) this.settlementCache.delete(key); + throw error; + } + }, + onReplay: signature => + recoveredRefundResponse(channelId, payer, requirements.network, signature), + postcondition: async (signature, phase) => { + const pendingView = phase === "pending"; + const observed = await this.fetchChannelUntil(requirements.network, channelId, channel => + pendingView + ? channel === undefined || closed.includes(channel.status) + : channel !== undefined && closed.includes(channel.status), + ); + if (pendingView && observed === undefined) return { ok: true as const, channel: undefined }; + if (!observed) { + return { + ok: false as const, + response: settlementPending( + requirements.network, + payer, + signature, + "request_close confirmed but the closing state is not visible yet", + ), + }; + } + this.assertClaimChannel(observed, payload.channelConfig, terms, requirements, closed); + return { ok: true as const, channel: observed }; + }, + }); + if (!("channel" in settled)) return settled; + return settled.channel + ? refundResponse(channelId, settled.channel, requirements.network, settled.signature) + : recoveredRefundResponse(channelId, payer, requirements.network, settled.signature); + } + + private async resolveTerms( + config: BatchChannelConfig, + requirements: PaymentRequirements, + binding: VoucherModeBinding = "requirements", + ): Promise { + const extra = requirements.extra; + if (!extra || (extra.paymentFlow !== undefined && extra.paymentFlow !== "authorization")) { + throw new Error(BatchError.PAYMENT_FLOW); + } + const feePayer = extra.feePayer; + if (typeof feePayer !== "string") throw new Error(BatchError.FEE_PAYER_MISMATCH); + const feePayerSigner = this.resolveFeePayer(feePayer); + const voucherSigner = voucherSignerFor(config, extra, binding); + if (config.payer === feePayer || config.payerAuthorizer === feePayer) { + throw new Error(BatchError.FEE_PAYER_MISMATCH); + } + const withdrawDelay = extra.withdrawDelay; + if ( + typeof withdrawDelay !== "number" || + !Number.isInteger(withdrawDelay) || + withdrawDelay < MIN_WITHDRAW_DELAY || + withdrawDelay > MAX_WITHDRAW_DELAY || + withdrawDelay < requirements.maxTimeoutSeconds + ) { + throw new Error(BatchError.WITHDRAW_DELAY_OUT_OF_RANGE); + } + if (config.withdrawDelay !== withdrawDelay) throw new Error(BatchError.WITHDRAW_DELAY_MISMATCH); + if (config.receiver !== requirements.payTo || config.token !== requirements.asset) { + throw new Error(BatchError.CHANNEL_STATE); + } + const receiverAuthorizer = extra.receiverAuthorizer; + if ( + typeof receiverAuthorizer !== "string" || + receiverAuthorizer.length === 0 || + receiverAuthorizer !== config.receiverAuthorizer + ) { + throw new Error(BatchError.RECEIVER_AUTHORIZER_MISMATCH); + } + const tokenProgram = requireTokenProgramHint(extra, BatchError.TOKEN_PROGRAM); + // A mint's owner is its token program, so the declared one is checked + // with an account read rather than a decode. + if (typeof this.signer.getAccountInfo !== "function") { + throw new Error( + "BatchSvmScheme requires getAccountInfo on the facilitator signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } + const mint = await this.signer.getAccountInfo(requirements.asset, requirements.network, { + commitment: "confirmed", + encoding: "base64", + }); + if (!mint || mint.owner.toString() !== tokenProgram) { + throw new Error(BatchError.TOKEN_PROGRAM); + } + const memo = extra.memo; + if (memo !== undefined && typeof memo !== "string") + throw new Error(BatchError.SETUP_TRANSACTION); + return { + feePayer, + feePayerSigner, + ...(memo !== undefined ? { memo } : {}), + receiverAuthorizer, + tokenProgram, + withdrawDelay, + voucherSigner, + }; + } + + private async deriveChannelId(config: BatchChannelConfig, feePayer: string): Promise { + return findPaymentChannelPda({ + authorizedSigner: config.payerAuthorizer, + mint: config.token, + openSlot: parseU64(config.openSlot, "channelConfig.openSlot"), + payee: feePayer, + payer: config.payer, + salt: parseU64(config.salt, "channelConfig.salt"), + }); + } + + private assertExpiry(expiresAt: number): void { + if (expiresAt !== CLIENT_VOUCHER_EXPIRES_AT) throw new Error(BatchError.VOUCHER_EXPIRY); + } + + // Signature and expiry of a client voucher. The caller checks the channel id. + private async verifySignedVoucher( + channelId: string, + voucher: Pick, + signerBase58: string, + ): Promise { + const cumulative = parseU64(voucher.maxClaimableAmount, "maxClaimableAmount"); + this.assertExpiry(voucher.expiresAt); + const valid = await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: cumulative, + expiresAt: BigInt(voucher.expiresAt), + }), + signatureBase58: voucher.signature, + signerBase58, + }); + if (!valid) throw new Error(BatchError.VOUCHER_SIGNATURE); + return cumulative; + } + + // Completed record, pending reconcile, or a new broadcast, then the postcondition. + // A completed signature is not marked complete again; onReplay skips that check. + private async settleDurably(args: { + key: string; + network: Network; + payer: string; + beforePending?: () => Promise; + beforeSend?: () => Promise; + send?: (onBroadcast: (signature: string, wire: string) => Promise) => Promise; + // Claims already broadcast inside submitRedemption, so they skip send. + broadcast?: () => Promise; + onCompleted?: (signature: string) => Promise | SettleResponse; + onReplay?: (signature: string) => SettleResponse; + postcondition: (signature: string, phase: DurablePhase) => Promise>; + }): Promise<{ signature: string; channel: T } | SettleResponse> { + const finish = async (signature: string, phase: DurablePhase) => { + const observed = await args.postcondition(signature, phase); + if (!observed.ok) return observed.response; + if (phase === "completed") return { signature, channel: observed.channel }; + const incomplete = await this.completeOrPending( + args.key, + signature, + args.network, + args.payer, + ); + return incomplete ?? { signature, channel: observed.channel }; + }; + const completed = await this.pendingStore.get(this.completedBroadcastKey(args.key)); + if (completed) + return args.onCompleted ? args.onCompleted(completed) : finish(completed, "completed"); + if (args.beforePending) await args.beforePending(); + const pending = await this.pendingStore.get(args.key); + if (pending) { + const recovered = await this.reconcileBroadcast(args.key, pending, args.network, args.payer); + if (!recovered.ok) return recovered.response; + return finish(recovered.signature, "pending"); + } + if (args.beforeSend) { + const early = await args.beforeSend(); + if (early) return early; + } + const send = args.send; + if (!args.broadcast && !send) throw new Error("settleDurably requires send"); + const sent = args.broadcast + ? await args.broadcast() + : await this.broadcastDurably(args.key, args.network, args.payer, send!); + if (!sent.ok) return sent.response; + if (sent.replayed && args.onReplay) return args.onReplay(sent.signature); + return finish(sent.signature, sent.replayed ? "completed" : "broadcast"); + } + + /** + * Broadcast through the pending-settlement record, or reconcile against a + * broadcast this facilitator already made and could not confirm. + * + * A confirmation wait that ends without an answer says nothing about the + * transaction: it may still land. Rebroadcasting then would escrow or redeem + * twice, so the signature is recorded before the wait and reconciled on the + * next attempt — including after a restart, when the in-memory duplicate + * cache is gone. + * + * @param key - Deterministic key for this exact piece of work + * @param network - Network the work is submitted to + * @param payer - Payer reported on a pending or failed response + * @param broadcast - Sends the transaction, reporting its signature to + * `onBroadcast` before waiting on confirmation + * @returns The confirmed signature, or the response to answer with + */ + private async broadcastDurably( + key: string, + network: Network, + payer: string, + broadcast: (onPrepared: (signature: string, wire: string) => Promise) => Promise, + ): Promise { + const completed = await this.pendingStore.get(this.completedBroadcastKey(key)); + if (completed) return { ok: true, replayed: true, signature: completed }; + const recorded = await this.pendingStore.get(key); + if (recorded) { + // Reconciled before the record is dropped, not after. Dropping it first + // would leave a concurrent retry — one that read no record because this + // call had already removed it — to broadcast the work a second time, + // with only the in-memory duplicate cache in the way. That cache is + // empty after a restart, which is exactly when a pending record is being + // reconciled. Two callers reconciling the same signature is harmless: + // they confirm the same transaction and reach the same answer. + return this.reconcileBroadcast(key, recorded, network, payer); + } + let signature: string; + try { + signature = await broadcast(async (broadcastSignature, wire) => { + await this.pendingStore.set( + `batch:transaction:${network}:${broadcastSignature}:wire`, + wire, + ); + if (!(await reserveBroadcast(this.pendingStore, key, broadcastSignature))) { + // Another worker owns this key; the bytes just written will never be sent. + await discardWire(this.pendingStore, network, broadcastSignature); + const existing = await this.pendingStore.get(key); + if (existing) throw new SettlementConfirmationTimeoutError(existing as Signature); + throw new Error("concurrent broadcast reservation changed"); + } + }); + } catch (error) { + const recorded = await this.pendingStore.get(key); + if (recorded) return this.reconcileBroadcast(key, recorded, network, payer); + const pending = pendingSignatureOf(error); + if (pending === undefined) throw error; + return { + ok: false, + response: await recordPendingOrTerminal( + this.pendingStore, + key, + pending, + payer, + network, + ErrSettlementPending, + "transaction_failed", + error, + ), + }; + } + // Keep the signature until the caller observes the operation-specific + // postcondition. Confirmation can precede a fresh account view when RPC + // requests are load-balanced across nodes. + // Submission helpers already confirmed and captured the execution slot. + // Do not add another status RPC to successful opens, top-ups or redemptions. + return { ok: true, replayed: false, signature }; + } + + /** + * Wrap submission confirmation so its existing RPC also supplies the read floor. + * + * @returns Submission transport with slot capture and no extra confirmation lookup + */ + private submissionSigner() { + return { + signTransaction: this.signer.signTransaction.bind(this.signer), + ...(this.signer.getLatestBlockhash + ? { getLatestBlockhash: this.signer.getLatestBlockhash.bind(this.signer) } + : {}), + simulateTransaction: this.signer.simulateTransaction.bind(this.signer), + sendTransaction: this.signer.sendTransaction.bind(this.signer), + confirmTransaction: async (signature: string, network: string) => { + const status = await this.signer.confirmTransaction(signature, network); + if (status?.slot !== undefined) this.rememberSlot(network, BigInt(status.slot)); + return status; + }, + }; + } + + /** + * Wait on a signature this facilitator already broadcast. + * + * @param key - The pending record's key + * @param signature - The recorded signature + * @param network - Network the transaction was submitted to + * @param payer - Payer reported on a pending or failed response + * @param resend - Resend previously recorded bytes when recovering a stopped attempt + * @returns The confirmed signature, or the response to answer with + */ + private async reconcileBroadcast( + key: string, + signature: string, + network: Network, + payer: string, + resend = true, + ): Promise { + const wire = await this.pendingStore + .get(`batch:transaction:${network}:${signature}:wire`) + .catch(() => undefined); + try { + // The process may have stopped after recording but before sending. + // Re-send the same signed bytes; never construct a replacement on timeout. + if (wire && resend) { + try { + await this.signer.sendTransaction(wire, network); + } catch { + /* confirm by identity */ + } + } + const status = await this.signer.confirmTransaction(signature, network, { + searchTransactionHistory: true, + }); + if (status && status.slot !== undefined) { + const slot = BigInt(status.slot); + if (slot > (this.confirmationSlots.get(network) ?? 0n)) + this.confirmationSlots.set(network, slot); + } + } catch (error) { + if (error instanceof TransactionOnchainFailureError) { + // A definite onchain rejection: nothing landed, so the record is + // dropped and the caller reports a failure rather than a pending. + await this.forgetPending(key, signature); + return { + ok: false, + response: { + errorMessage: error.message, + errorReason: "transaction_failed", + network, + payer, + success: false, + transaction: signature, + }, + }; + } + if (await broadcastExpiredWithoutLanding(this.signer, signature, network, wire)) { + // The blockhash left its validity window and the network has no record + // of the signature: the bytes can never land, so the queue is released + // instead of staying pending until an operator clears it. + await this.forgetPending(key, signature); + await discardWire(this.pendingStore, network, signature); + return { + ok: false, + response: { + errorMessage: `transaction ${signature} expired before confirmation: its blockhash is no longer valid and the network has no record of it`, + errorReason: "transaction_failed", + network, + payer, + success: false, + transaction: signature, + }, + }; + } + return { + ok: false, + response: await recordPendingOrTerminal( + this.pendingStore, + key, + signature, + payer, + network, + ErrSettlementPending, + "transaction_failed", + error, + ), + }; + } + return { ok: true, replayed: false, signature }; + } + + /** + * Mark a confirmed operation replayable before its HTTP success is returned. + * + * @param key - Deterministic operation key + * @param signature - Confirmed transaction signature + * @param network - Network containing the transaction + */ + private async completeBroadcast(key: string, signature: string, network: string): Promise { + // Write completion first. A crash before the pending delete leaves both + // records, and completed is deliberately checked first on recovery. + await this.pendingStore.set(this.completedBroadcastKey(key), signature); + await this.forgetPending(key, signature); + // The completed identity/result now survives response loss; signed bytes + // are no longer needed for rebroadcast. + await discardWire(this.pendingStore, network, signature); + } + + private async completeOrPending( + key: string, + signature: string, + network: Network, + payer: string, + ): Promise { + try { + await this.completeBroadcast(key, signature, network); + return undefined; + } catch (error) { + return settlementPending( + network, + payer, + signature, + `operation confirmed but completion could not be persisted: ${String(error)}`, + ); + } + } + + private completedBroadcastKey(key: string): string { + return `${key}${COMPLETED_BROADCAST_SUFFIX}`; + } + + /** + * Drop a pending record; a storage hiccup must not mask a confirmed result. + * + * @param key - The pending record to drop + * @param signature - The transaction being completed + */ + private async forgetPending(key: string, signature: string): Promise { + try { + if (this.pendingStore.deleteIfEquals) { + await this.pendingStore.deleteIfEquals(key, signature); + } else if ((await this.pendingStore.get(key)) === signature) { + await this.pendingStore.delete(key); + } + } catch { + // Best effort: the work is confirmed either way. + } + } + + /** + * Submit a redemption batch: claim or distribute. + * + * Simulation is explicit and its failure is reported as + * `settlement_simulation` rather than as a generic send error. A batch packs + * several channels into one transaction, so a caller that cannot tell + * simulation from transport cannot tell a poisoned batch from a flaky node — + * and would retry the same doomed batch forever. The signer then broadcasts + * with preflight skipped, so the node does not simulate the same bytes again. + * + * @param feePayer - Address of the managed fee payer to sign with + * @param network - CAIP-2 network to submit against + * @param instructions - The batch's channel instructions + * @param key - Deterministic key for this exact batch + * @param payer - Payer reported on a pending or failed response + * @returns The confirmed signature, or the response to answer with + */ + private async submitRedemption( + feePayer: string, + network: Network, + instructions: readonly ServerInstruction[], + key: string, + payer: string, + ): Promise< + { ok: true; replayed: boolean; signature: Signature } | { ok: false; response: SettleResponse } + > { + const broadcast = await this.broadcastDurably(key, network, payer, async onBroadcast => { + try { + return await submitChannelTransactionWithSigner( + this.resolveFeePayer(feePayer), + this.submissionSigner(), + network, + instructions, + { onPrepared: onBroadcast }, + ); + } catch (error) { + if (error instanceof ChannelSimulationError) { + throw new Error(`${BatchError.SETTLEMENT_SIMULATION}: ${String(error.cause)}`); + } + throw error; + } + }); + return broadcast.ok + ? { + ok: true, + replayed: broadcast.replayed, + signature: broadcast.signature as Signature, + } + : { ok: false, response: broadcast.response }; + } + + private resolveFeePayer(feePayer: string): FacilitatorSigningCapabilities { + if (!this.signer.getAddresses().some(value => value === feePayer)) { + throw new Error(BatchError.FEE_PAYER_MISMATCH); + } + return this.signer.getSigner!(address(feePayer)); + } + + /** + * Read a channel account through the facilitator signer. + * + * Every read goes through the same transport that signs, simulates and + * broadcasts, so an operator configuring one RPC does not find reads + * quietly answered by another. + * + * @param network - CAIP-2 network to read from + * @param channelId - Channel PDA (base58) + * @returns The decoded channel, or undefined when the account is absent + */ + private async readChannel(network: string, channelId: string): Promise { + if (typeof this.signer.getAccountInfo !== "function") { + throw new Error( + "BatchSvmScheme requires getAccountInfo on the facilitator signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } + const minContextSlot = this.confirmationSlots.get(network); + let account: FacilitatorAccountInfo | null; + for (let attempt = 0; ; attempt += 1) { + try { + account = await this.signer.getAccountInfo(channelId, network, { + commitment: "confirmed", + encoding: "base64", + minContextSlot, + }); + break; + } catch (error) { + // A slot floor is only advisory for the read: a load-balanced RPC node + // that has not reached the remembered confirmation slot rejects it. + // Give the backend time to catch up instead of failing a deposit or + // claim that merely wanted the channel's current state. Also backoff on + // transient RPC rate limits, which are common on public devnet endpoints. + const retry = + attempt + 1 < CHANNEL_READ_ATTEMPTS && + (minContextSlot !== undefined || isTransientRpcError(error)); + if (!retry) throw error; + await this.waitForChannelRead(attempt); + } + } + if (!account) return undefined; + const encoded = Array.isArray(account.data) ? account.data[0] : account.data; + return getChannelDecoder().decode(Buffer.from(encoded, "base64")); + } + + private async fetchChannel(network: string, channelId: string): Promise { + for (let attempt = 0; attempt < CHANNEL_READ_ATTEMPTS; attempt += 1) { + const channel = await this.readChannel(network, channelId); + if (channel) return channel; + if (attempt + 1 < CHANNEL_READ_ATTEMPTS) { + await new Promise(resolve => + setTimeout(resolve, CHANNEL_READ_INITIAL_BACKOFF_MS * 2 ** attempt), + ); + } + } + throw new Error(`${BatchError.CHANNEL_STATE}: channel is not visible after confirmation`); + } + + /** + * Poll until an operation-specific channel postcondition is visible. + * + * @param network - Network to read + * @param channelId - Channel PDA + * @param predicate - Required postcondition + * @returns The matching channel, `undefined` for a matching absent account, or `false` on timeout + */ + private async fetchChannelUntil( + network: string, + channelId: string, + predicate: (channel: Channel | undefined) => boolean, + ): Promise { + for (let attempt = 0; attempt < CHANNEL_READ_ATTEMPTS; attempt += 1) { + try { + const channel = await this.readChannel(network, channelId); + if (predicate(channel)) return channel; + } catch { + /* A lagging backend may reject minContextSlot; retry. */ + } + if (attempt + 1 < CHANNEL_READ_ATTEMPTS) await this.waitForChannelRead(attempt); + } + return false; + } + + /** + * Poll a batch atomically from the caller's perspective until its predicate holds. + * + * @param network - Network to read + * @param channelIds - Channel PDAs + * @param predicate - Required batch postcondition + * @returns Matching channels, or `undefined` on timeout + */ + private async fetchChannelsUntil( + network: string, + channelIds: readonly string[], + predicate: (channels: readonly (Channel | undefined)[]) => boolean, + ): Promise { + for (let attempt = 0; attempt < CHANNEL_READ_ATTEMPTS; attempt += 1) { + try { + const channels = await Promise.all(channelIds.map(id => this.readChannel(network, id))); + if (predicate(channels) && channels.every((value): value is Channel => value !== undefined)) + return channels; + } catch { + /* Keep transaction identity while the RPC catches up. */ + } + if (attempt + 1 < CHANNEL_READ_ATTEMPTS) await this.waitForChannelRead(attempt); + } + return undefined; + } + + private rememberSlot(network: string, slot: bigint): void { + if (slot > (this.confirmationSlots.get(network) ?? 0n)) + this.confirmationSlots.set(network, slot); + } + + private async waitForChannelRead(attempt: number): Promise { + await new Promise(resolve => + setTimeout(resolve, CHANNEL_READ_INITIAL_BACKOFF_MS * 2 ** attempt), + ); + } + + private assertRecoveredClaims( + channels: readonly Channel[], + prepared: readonly PreparedClaim[], + requirements: PaymentRequirements, + ): void { + for (let index = 0; index < prepared.length; index += 1) { + const item = prepared[index]!; + this.assertClaimChannel( + channels[index]!, + item.claim.channelConfig, + item.terms, + requirements, + [ + ChannelStatus.Open, + ChannelStatus.Sealed, + ChannelStatus.Closing, + ChannelStatus.Distributed, + ], + ); + } + } + + private assertDepositChannel( + channel: Channel, + validated: ValidatedDeposit, + requirements: PaymentRequirements, + ): void { + this.assertClaimChannel( + channel, + validated.payload.channelConfig, + validated.terms, + requirements, + [ChannelStatus.Open], + ); + if (channel.deposit !== validated.expectedDeposit) { + throw new Error(`${BatchError.CHANNEL_STATE}: confirmed deposit mismatch`); + } + } + + private assertServerModeProof( + payload: Extract, + channelId: string, + requirements: PaymentRequirements, + proofBound: ProofAmountBound = "exact", + ): Promise { + return checkServerModeProof(payload, channelId, requirements, proofBound); + } + + private assertClaimChannel( + channel: Channel, + config: BatchChannelConfig, + terms: Pick, + requirements: PaymentRequirements, + allowedStatuses: readonly ChannelStatus[], + ): void { + const expectedDistributionHash = getChannelDistributionHash([ + { bps: FULL_SPLIT_BPS, recipient: requirements.payTo }, + ]); + if ( + channel.discriminator !== AccountDiscriminator.Channel || + !allowedStatuses.includes(channel.status as ChannelStatus) || + channel.payer !== config.payer || + channel.payee !== terms.feePayer || + channel.rentPayer !== terms.feePayer || + channel.authorizedSigner !== config.payerAuthorizer || + channel.mint !== requirements.asset || + channel.gracePeriod !== terms.withdrawDelay || + channel.salt !== BigInt(config.salt) || + channel.openSlot !== BigInt(config.openSlot) || + channel.distributionHash.length !== expectedDistributionHash.length || + channel.distributionHash.some((value, index) => value !== expectedDistributionHash[index]) + ) { + throw new Error(BatchError.CHANNEL_STATE); + } + } + + private async distributeInstruction( + channelId: string, + channel: Channel, + terms: BatchTerms, + requirements: PaymentRequirements, + ): Promise { + return buildDistributeInstruction({ + channelId, + mint: channel.mint, + network: requirements.network, + payee: channel.payee, + payer: channel.payer, + rentPayer: channel.rentPayer, + splits: [{ bps: FULL_SPLIT_BPS, recipient: requirements.payTo }], + tokenProgram: terms.tokenProgram, + }); + } + + private sealDependencies(): SealDependencies { + return { + assertClaimChannel: (channel, config, terms, requirements, allowed) => + this.assertClaimChannel(channel, config, terms as BatchTerms, requirements, allowed), + completeOrPending: (key, signature, network, payer) => + this.completeOrPending(key, signature, network, payer), + deriveChannelId: (config, feePayer) => this.deriveChannelId(config, feePayer), + distributeInstruction: (channelId, channel, terms, requirements) => + this.distributeInstruction(channelId, channel, terms as BatchTerms, requirements), + fetchChannel: (network, channelId) => this.fetchChannel(network, channelId), + nowSeconds: () => Math.floor(Date.now() / 1000), + pendingStore: this.pendingStore, + readChannel: (network, channelId) => this.readChannel(network, channelId), + getDelegatedCallerIdentity: (network, channelId) => + storedDelegatedIdentity(this.delegatedReceiverAuth, network, channelId), + isDelegatedAuthorizer: bound => isDelegatedAuthorizer(this.delegatedReceiverAuth, bound), + resolveDelegatedIdentity: ctx => resolveDelegatedIdentity(this.delegatedReceiverAuth, ctx), + resolveReceiverAuthorizer: (network, channelId) => + readReceiverAuthorizer( + this.receiverAuthorizers, + this.receiverBindingHistoryReader, + network, + channelId, + ), + resolveTerms: (config, requirements, binding) => + this.resolveTerms(config, requirements, binding ?? "requirements"), + settlementCache: this.settlementCache, + submitRedemption: (feePayer, network, instructions, key, payer) => + this.submitRedemption(feePayer, network, instructions, key, payer), + trackChannel: record => this.trackChannel(record), + }; + } + + /** + * Persist the open's receiver authorizer before it is broadcast. + * + * Store only: the write must succeed and read back as the same key, or the + * open is not sent. Store and an explicit history reader: a failed write + * still allows the open, because history is the fallback at close. History + * only: nothing is written. + * + * @param channelId - Channel PDA + * @param network - CAIP-2 network the channel is opening on + * @param receiverAuthorizer - Key carried in the open's binding memo + */ + private async bindReceiverAuthorizer( + channelId: string, + network: Network, + receiverAuthorizer: string, + ): Promise { + const store = this.receiverAuthorizers; + if (!store) return; + try { + await store.bind({ channelId, network, receiverAuthorizer }); + } catch (error) { + if (this.receiverBindingHistoryReader) return; + throw error; + } + if (this.receiverBindingHistoryReader) return; + const stored = await store.get(network, channelId); + if (stored?.receiverAuthorizer !== receiverAuthorizer) { + throw new Error( + `${BatchError.RECEIVER_BINDING_UNAVAILABLE}: receiver authorizer was not stored for ${channelId}`, + ); + } + } + + private trackChannel( + record: Omit, + ): Promise { + // Every upsert is facilitator-visible activity: it resets the idle clock. + const now = Date.now(); + return this.channelStorage.upsert({ ...record, firstSeenAt: now, lastActivityAt: now }); + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/seal.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/seal.ts new file mode 100644 index 0000000000..01e7a5f84d --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/seal.ts @@ -0,0 +1,506 @@ +/** + * Server-authored cooperative close of a `Closing` channel (`type: "seal"`). + * + * Once a payer calls `request_close`, program `settle` is unavailable and only + * the channel `payee` (the facilitator) can apply a final voucher through + * `settle_and_seal` during the grace period. Without this path a server that + * was serving requests a minute ago has no way to collect vouchers above the + * onchain watermark once the payer walks. The server proves it authored the + * request with a `CloseAuthorization` signed by the receiver authorizer bound + * to the channel by its open's binding memo, so a payer holding a stale + * voucher cannot freeze the watermark low. A cooperative refund runs the same + * close on an `Open` channel. + */ + +import type { + FacilitatorContext, + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, +} from "@x402/core/types"; + +import { verifyRequestCloseTransaction } from "../../payment-channels/close"; +import type { Channel } from "../../payment-channels/generated/accounts/channel"; +import { + buildSettleAndSealInstructions, + ChannelStatus, + type ServerInstruction, +} from "../../payment-channels/onchain"; +import { parseU64 } from "../../payment-channels/open"; +import type { PaymentChannelRecord } from "../../payment-channels/storage"; +import { encodeVoucherMessageBytes, verifyVoucherSignature } from "../../payment-channels/voucher"; +import type { SettlementCache } from "../../settlement-cache"; +import { verifyCloseAuthorization } from "../closeAuthorization"; +import { CLIENT_VOUCHER_EXPIRES_AT } from "../constants"; +import { BatchError } from "../errors"; +import { + isBatchVoucher, + type BatchChannelConfig, + type BatchRefundPayload, + type BatchSealPayload, + type BatchVoucher, +} from "../types"; +import type { BatchDelegatedSettleContext } from "./delegatedAuthStore"; +import { requireReceiverAuthorizer } from "./receiverAuthorizerStore"; +import type { BatchPendingSettlementStore } from "./recovery"; +import { + CHANNEL_BUSY, + refundResponse, + sealResponse, + settleFailure, + settlementPending, +} from "./responses"; +import type { BatchTerms } from "./types"; + +/** Terms `seal` needs: everything {@link BatchTerms} carries except who signs vouchers. */ +export type SealTerms = Omit; + +/** A server-authored close: `seal` of a `Closing` channel or cooperative `refund` of an `Open` one. */ +export type CloseIntent = "seal" | "refund"; + +/** Operator ceilings a sponsored `request_close` must respect. */ +export interface RefundLimits { + maxComputeUnits?: number | undefined; + maxPriorityFeeMicroLamports?: number | undefined; +} + +/** A validated refund; `requestClose` is set only on the sponsored fallback path. */ +export interface PreparedRefund { + channelId: string; + terms: SealTerms; + requestClose?: string | undefined; +} + +/** Scheme internals the seal path borrows, so it can live outside the scheme file. */ +export interface SealDependencies { + pendingStore: BatchPendingSettlementStore; + settlementCache: SettlementCache; + resolveTerms( + config: BatchChannelConfig, + requirements: PaymentRequirements, + binding?: "requirements" | "payload", + ): Promise; + resolveReceiverAuthorizer(network: Network, channelId: string): Promise; + /** True when `bound` is this facilitator's delegated receiver authorizer. */ + isDelegatedAuthorizer(bound: string): boolean; + /** Caller identity for this settle, or undefined when the caller is unauthenticated. */ + resolveDelegatedIdentity(ctx: BatchDelegatedSettleContext): Promise; + /** Identity bound at open, or undefined when this facilitator has no row. */ + getDelegatedCallerIdentity(network: Network, channelId: string): Promise; + deriveChannelId(config: BatchChannelConfig, feePayer: string): Promise; + fetchChannel(network: string, channelId: string): Promise; + readChannel(network: string, channelId: string): Promise; + assertClaimChannel( + channel: Channel, + config: BatchChannelConfig, + terms: SealTerms, + requirements: PaymentRequirements, + allowedStatuses: readonly ChannelStatus[], + ): void; + distributeInstruction( + channelId: string, + channel: Channel, + terms: SealTerms, + requirements: PaymentRequirements, + ): Promise; + submitRedemption( + feePayer: string, + network: Network, + instructions: readonly ServerInstruction[], + key: string, + payer: string, + ): Promise< + { ok: true; replayed: boolean; signature: string } | { ok: false; response: SettleResponse } + >; + completeOrPending( + key: string, + signature: string, + network: Network, + payer: string, + ): Promise; + trackChannel(record: Omit): Promise; + nowSeconds(): number; +} + +/** + * Reject a `Closing` channel with the dedicated code, so a server can tell + * "the payer started a forced close, retry with `seal`" from every other + * channel-state mismatch (spec 4.5). + * + * @param channel - Decoded channel account + * @param channelId - Channel PDA, for the message + */ +export function assertNotClosing(channel: Channel, channelId: string): void { + if (channel.status === ChannelStatus.Closing) { + throw new Error( + `${BatchError.CHANNEL_CLOSING}: ${channelId} is closing; apply the final voucher with a seal payload`, + ); + } +} + +/** + * Apply the server's latest voucher with `settle_and_seal` and pay out with a + * sealed `distribute`, in one transaction: to a `Closing` channel before the + * payer's grace period ends (`seal`), or to an `Open` one (`refund`). + * + * @param deps - Scheme internals + * @param payment - The settle request envelope + * @param payload - The close to apply + * @param requirements - The server's requirements for the channel + * @param intent - Which close this is; decides the required channel status + * @param context - Facilitator extensions (used by delegated caller identity) + * @returns The settle response + */ +export async function settleSeal( + deps: SealDependencies, + payment: PaymentPayload, + payload: BatchSealPayload, + requirements: PaymentRequirements, + intent: CloseIntent, + context?: FacilitatorContext, +): Promise { + const network = requirements.network; + const payer = payload.channelConfig.payer; + const terms = await deps.resolveTerms( + payload.channelConfig, + requirements, + intent === "seal" ? "payload" : "requirements", + ); + const channelId = await deps.deriveChannelId(payload.channelConfig, terms.feePayer); + if (channelId !== payload.channelId) throw new Error(BatchError.CHANNEL_ID_MISMATCH); + const cumulative = await verifyCloseVoucher(payload.voucher, payload.channelConfig, channelId); + + await authenticateServer( + deps, + payload, + requirements, + terms, + channelId, + cumulative, + intent, + context, + ); + + // Namespace from spec Phase 5: ("close", channelId, maxClaimableAmount). + const key = `batch:${intent}:${network}:${channelId}:${cumulative}`; + const previous = await deps.pendingStore.get(`${key}:result`); + if (previous) return JSON.parse(previous) as SettleResponse; + + const channel = await deps.fetchChannel(network, channelId); + const requiredStatus = intent === "seal" ? ChannelStatus.Closing : ChannelStatus.Open; + if (channel.status !== requiredStatus) { + throw new Error( + `${BatchError.CLOSE_STATE}: ${intent} applies only to a ${ChannelStatus[requiredStatus]} channel; observed status ${channel.status}`, + ); + } + deps.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [requiredStatus]); + const deadline = channel.closureStartedAt + BigInt(channel.gracePeriod); + if (intent === "seal" && BigInt(deps.nowSeconds()) >= deadline) { + throw new Error( + `${BatchError.CLOSE_STATE}: grace period elapsed; the permissionless seal path applies`, + ); + } + const settled = channel.settlement.settled; + if (cumulative < settled || cumulative > channel.deposit) { + throw new Error( + `${BatchError.CLOSE_STATE}: voucher ${cumulative} must lie within settled ${settled} and deposit ${channel.deposit}`, + ); + } + // An equal voucher would fail the program's strict monotonicity check, so + // the channel is sealed at its current watermark instead (spec 4.5). + const instructions: ServerInstruction[] = [ + ...buildSettleAndSealInstructions({ + channelId, + payeeSigner: terms.feePayerSigner, + ...(cumulative > settled + ? { + voucher: { + authorizedSigner: payload.channelConfig.payerAuthorizer, + cumulativeAmount: cumulative, + expiresAt: BigInt(CLIENT_VOUCHER_EXPIRES_AT), + signatureBase58: payload.voucher.signature, + }, + } + : {}), + }), + await deps.distributeInstruction(channelId, channel, terms, requirements), + ]; + if (deps.settlementCache.isDuplicate(key)) { + return settleFailure(payment.accepted.network, CHANNEL_BUSY, payer); + } + const submitted = await deps.submitRedemption(terms.feePayer, network, instructions, key, payer); + if (!submitted.ok) return submitted.response; + + // The sealed distribute may deallocate the PDA outright; a read still in + // the pre-close status means the confirmed state is not visible yet. + const observed = await deps.readChannel(network, channelId); + if (observed && observed.status === requiredStatus) { + return settlementPending( + network, + payer, + submitted.signature, + `${intent} confirmed but the sealed state is not visible yet`, + ); + } + const incomplete = await deps.completeOrPending(key, submitted.signature, network, payer); + if (incomplete) return incomplete; + + const sealed = sealResponse({ + channelId, + deposit: channel.deposit, + finalSettled: cumulative, + network, + paidToReceiver: cumulative - channel.settlement.payoutWatermark, + payer, + transaction: submitted.signature, + }); + const response = + intent === "refund" ? { ...sealed, amount: (channel.deposit - cumulative).toString() } : sealed; + await deps.pendingStore.set(`${key}:result`, JSON.stringify(response)); + // A confirmed seal is facilitator-visible activity; cleanup will find the + // PDA gone or Distributed and reclaim rent from there. + await deps.trackChannel({ + channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + network, + payTo: requirements.payTo, + tokenProgram: terms.tokenProgram, + }); + return response; +} + +/** + * Validate a refund and pick its path: a cooperative close when the + * channel's receiver binding resolves, otherwise the payer-signed + * `request_close`, returned as `requestClose` once verified. + * + * @param deps - Scheme internals + * @param payload - The refund payload + * @param requirements - The server's requirements for the channel + * @param limits - Operator ceilings the `request_close` must respect + * @param context - Facilitator extensions (used by delegated caller identity) + * @returns Channel, terms, and the verified `request_close` for the fallback path + */ +export async function prepareRefund( + deps: SealDependencies, + payload: BatchRefundPayload, + requirements: PaymentRequirements, + limits: RefundLimits, + context?: FacilitatorContext, +): Promise { + if ("amount" in payload) { + // The program returns all unused escrow; a partial close is not a thing + // this scheme can honor (spec 4.3). + throw new Error( + `${BatchError.CLOSE_AMOUNT_UNSUPPORTED}: refund returns the full unused escrow`, + ); + } + const terms = await deps.resolveTerms(payload.channelConfig, requirements); + const channelId = await deps.deriveChannelId(payload.channelConfig, terms.feePayer); + const voucher = payload.voucher; + if (!isBatchVoucher(voucher)) { + const serverMode = (payload.channelConfig.voucherSigner ?? "client") === "server"; + throw new Error( + serverMode + ? `${BatchError.VOUCHER_SIGNATURE}: refund missing operator voucher` + : `${BatchError.VOUCHER_SIGNATURE}: refund missing voucher`, + ); + } + const cumulative = await verifyCloseVoucher(voucher, payload.channelConfig, channelId); + const channel = await deps.readChannel(requirements.network, channelId); + if (channel && (cumulative < channel.settlement.settled || cumulative > channel.deposit)) { + throw new Error( + `${BatchError.CUMULATIVE_AMOUNT_MISMATCH}: refund voucher must lie within settled and deposit`, + ); + } + + void context; + const bound = await deps.resolveReceiverAuthorizer(requirements.network, channelId); + if (bound !== undefined) { + requireReceiverAuthorizer(bound, terms.receiverAuthorizer, channelId); + return { channelId, terms }; + } + if (payload.transaction === undefined) { + throw new Error( + `${BatchError.RECEIVER_BINDING_UNAVAILABLE}: resend the refund with a payer-signed request_close transaction`, + ); + } + try { + await verifyRequestCloseTransaction(payload.transaction, { + channelId, + feePayer: terms.feePayer, + maxComputeUnits: limits.maxComputeUnits, + maxPriorityFeeMicroLamports: limits.maxPriorityFeeMicroLamports, + memo: terms.memo, + payer: payload.channelConfig.payer, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${BatchError.REFUND_TRANSACTION}: ${detail}`); + } + return { channelId, requestClose: payload.transaction, terms }; +} + +/** + * Verify a refund against the channel's current onchain state. + * + * @param deps - Scheme internals + * @param payload - The refund payload + * @param requirements - The server's requirements for the channel + * @param limits - Operator ceilings the `request_close` must respect + * @param context - Facilitator extensions (used by delegated caller identity) + * @returns The channel, its id, and the resolved terms + */ +export async function validateRefund( + deps: SealDependencies, + payload: BatchRefundPayload, + requirements: PaymentRequirements, + limits: RefundLimits, + context?: FacilitatorContext, +): Promise<{ channel: Channel; channelId: string; terms: SealTerms }> { + const { channelId, terms } = await prepareRefund(deps, payload, requirements, limits, context); + const channel = await deps.fetchChannel(requirements.network, channelId); + deps.assertClaimChannel(channel, payload.channelConfig, terms, requirements, [ + ChannelStatus.Open, + ChannelStatus.Closing, + ]); + return { channel, channelId, terms }; +} + +/** + * Close an `Open` channel cooperatively for a refund. A channel whose payer + * already started a sponsored close reports that close instead. + * + * @param deps - Scheme internals + * @param payment - The settle request envelope + * @param payload - The refund payload + * @param requirements - The server's requirements for the channel + * @param prepared - The validated refund + * @param context - Facilitator extensions (used by delegated caller identity) + * @returns The settle response + */ +export async function settleCooperativeRefund( + deps: SealDependencies, + payment: PaymentPayload, + payload: BatchRefundPayload, + requirements: PaymentRequirements, + prepared: PreparedRefund, + context?: FacilitatorContext, +): Promise { + const { channelId, terms } = prepared; + const current = await deps.readChannel(requirements.network, channelId); + if (current?.status === ChannelStatus.Closing) { + deps.assertClaimChannel(current, payload.channelConfig, terms, requirements, [ + ChannelStatus.Closing, + ]); + return refundResponse(channelId, current, requirements.network, ""); + } + const seal: BatchSealPayload = { + channelConfig: payload.channelConfig, + channelId, + type: "seal", + voucher: payload.voucher, + ...(payload.closeAuthorization ? { closeAuthorization: payload.closeAuthorization } : {}), + }; + return settleSeal(deps, payment, seal, requirements, "refund", context); +} + +/** + * Check a close voucher: this channel, no expiry, signed by the payer authorizer. + * + * @param voucher - The voucher to apply + * @param config - Channel configuration naming the payer authorizer + * @param channelId - Channel PDA + * @returns The voucher's cumulative amount + */ +async function verifyCloseVoucher( + voucher: BatchVoucher, + config: BatchChannelConfig, + channelId: string, +): Promise { + if (voucher.channelId !== channelId) throw new Error(BatchError.CHANNEL_ID_MISMATCH); + if (voucher.expiresAt !== CLIENT_VOUCHER_EXPIRES_AT) throw new Error(BatchError.VOUCHER_EXPIRY); + const cumulative = parseU64(voucher.maxClaimableAmount, "maxClaimableAmount"); + const valid = await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: cumulative, + expiresAt: BigInt(CLIENT_VOUCHER_EXPIRES_AT), + }), + signatureBase58: voucher.signature, + signerBase58: config.payerAuthorizer, + }); + if (!valid) throw new Error(BatchError.VOUCHER_SIGNATURE); + return cumulative; +} + +/** + * Bind the request to the server through its `CloseAuthorization`. + * + * The key it must verify against is the receiver authorizer bound to the + * channel by its open's binding memo. A key that merely appears in the + * request is never trusted on its own (spec §3). + * + * @param deps - Scheme internals + * @param payload - The close payload + * @param requirements - The server's requirements + * @param terms - Resolved terms + * @param channelId - Channel PDA + * @param cumulative - Final voucher amount the authorization must bind + * @param intent - Which close this is; selects the delegated identity step + * @param context - Facilitator extensions (used by delegated caller identity) + */ +async function authenticateServer( + deps: SealDependencies, + payload: BatchSealPayload, + requirements: PaymentRequirements, + terms: SealTerms, + channelId: string, + cumulative: bigint, + intent: CloseIntent, + context?: FacilitatorContext, +): Promise { + const bound = requireReceiverAuthorizer( + await deps.resolveReceiverAuthorizer(requirements.network, channelId), + terms.receiverAuthorizer, + channelId, + ); + if (deps.isDelegatedAuthorizer(bound)) { + const identity = await deps.resolveDelegatedIdentity({ + channelId, + facilitatorContext: context, + network: requirements.network, + payer: payload.channelConfig.payer, + step: intent === "refund" ? "refund" : "seal", + }); + const stored = await deps.getDelegatedCallerIdentity(requirements.network, channelId); + if (!identity || identity !== stored) { + throw new Error( + `${BatchError.DELEGATED_UNAUTHENTICATED}: caller identity does not match the channel binding`, + ); + } + return; + } + if (!payload.closeAuthorization) { + throw new Error(`${BatchError.CLOSE_AUTHORIZATION}: a closeAuthorization is required`); + } + const binding = { + channelId, + feePayer: terms.feePayer, + maxClaimableAmount: cumulative, + network: requirements.network, + voucherExpiresAt: 0n, + }; + const valid = await verifyCloseAuthorization( + payload.closeAuthorization, + binding, + bound, + requirements.maxTimeoutSeconds, + deps.nowSeconds(), + ); + if (valid) return; + throw new Error( + `${BatchError.CLOSE_AUTHORIZATION}: signature does not bind this close or is outside its validity window`, + ); +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/types.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/types.ts new file mode 100644 index 0000000000..b211c40491 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/types.ts @@ -0,0 +1,95 @@ +import type { Network, SettleResponse } from "@x402/core/types"; + +import type { FacilitatorSigningCapabilities } from "../../signer"; +import type { + BatchClaimPayload, + BatchDepositPayload, + BatchProof, + BatchSettlePayload, +} from "../types"; +import type { BatchReceiverAuthorizerStore } from "./receiverAuthorizerStore"; + +/** Terms resolved from a channel config and the facilitator's fee payer. */ +export type BatchTerms = { + feePayer: string; + feePayerSigner: FacilitatorSigningCapabilities; + receiverAuthorizer: string; + tokenProgram: string; + withdrawDelay: number; + memo?: string | undefined; + voucherSigner: "client" | "server"; +}; + +/** A deposit payload whose terms, channel, and amounts have been checked. */ +export type ValidatedDeposit = { + payload: BatchDepositPayload; + terms: BatchTerms; + channelId: string; + deposit: bigint; + expectedDeposit: bigint; + isTopUp: boolean; + proof: BatchProof; + /** Client: signed cumulative. Server: metered charge (`requirements.amount`). */ + proofAmount: bigint; +}; + +/** Outcome of a durable broadcast: landed bytes, or a terminal settle response. */ +export type DurableBroadcastResult = + | { ok: true; replayed: boolean; signature: string } + | { ok: false; response: SettleResponse }; + +/** One claim whose channel, voucher, and fee payer are ready to redeem. */ +export type PreparedClaim = { + claim: BatchClaimPayload["claims"][number]; + channelId: string; + feePayer: string; + cumulative: bigint; + expiresAt: number; + payTo: string; + tokenProgram: string; + terms: BatchTerms; +}; + +/** One channel whose settled amount is ready to distribute. */ +export type PreparedDistribution = { + channelConfig: BatchSettlePayload["channels"][number]["channelConfig"]; + channelId: string; + feePayer: string; + terms: BatchTerms; +}; + +/** Verify requires the proof to equal the advertised amount; settle allows a lower metered charge. */ +export type ProofAmountBound = "exact" | "ceiling"; + +/** + * Client payments must match the requirements. Redemption reads the mode from + * the payload, because one worker requirements object covers both modes. + */ +export type VoucherModeBinding = "requirements" | "payload"; + +/** The two places a facilitator may read a channel's receiver-authorizer binding. */ +export type BindingSourceConfig = { + receiverAuthorizerStore?: BatchReceiverAuthorizerStore | undefined; + receiverBindingHistoryReader?: BatchReceiverBindingHistoryReader | undefined; +}; + +/** One signature touching a channel account, newest-first as returned by RPC. */ +export interface BatchReceiverBindingHistorySignature { + signature: string; + /** RPC error for a failed transaction, or null when it succeeded. */ + err: unknown; +} + +/** + * Reconstructs a channel's receiver-authorizer binding from its open transaction. + * A pruned history fails closed: `getTransaction` returns null. + */ +export interface BatchReceiverBindingHistoryReader { + getSignaturesForAddress( + network: Network, + address: string, + options?: { before?: string; limit?: number }, + ): Promise; + /** Confirmed transaction bytes, or null when the signature is unknown. */ + getTransaction(network: Network, signature: string): Promise; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/voucherMode.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/voucherMode.ts new file mode 100644 index 0000000000..9321659ca0 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/facilitator/voucherMode.ts @@ -0,0 +1,140 @@ +import type { PaymentRequirements } from "@x402/core/types"; + +import { parseU64 } from "../../payment-channels/open"; +import { verifyBatchAuthorization } from "../authorization"; +import { BatchError } from "../errors"; +import type { BatchChannelConfig, BatchPayload, BatchRefundPayload } from "../types"; +import type { ProofAmountBound, VoucherModeBinding } from "./types"; + +/** + * Verify server-mode refund identity: close intent via `authorizedAmount` zero. + * + * @param payload - Refund payload without an operator voucher + * @param channelId - Derived channel PDA + */ +export async function assertServerModeRefundProof( + payload: BatchRefundPayload, + channelId: string, +): Promise { + if (payload.voucher !== undefined) { + throw new Error(`${BatchError.VOUCHER_SIGNATURE}: invalid payer proof`); + } + const authorization = payload.authorization; + if (!authorization) throw new Error(`${BatchError.VOUCHER_SIGNATURE}: payer proof missing`); + const authorized = parseU64(authorization.authorizedAmount, "authorizedAmount"); + if ( + authorized !== 0n || + authorization.channelId !== channelId || + authorization.payer !== payload.channelConfig.payer || + typeof authorization.requestId !== "string" || + authorization.requestId.length === 0 || + !(await verifyBatchAuthorization(authorization, payload.channelConfig.payerAuthorizer)) + ) { + throw new Error(`${BatchError.VOUCHER_SIGNATURE}: invalid payer proof`); + } +} + +/** + * Verify the payer proof behind a server-signed request. + * + * Verify requires `requirements.amount` to equal the signed ceiling. Settle + * allows a metered charge at or below that ceiling. Signature, channel, payer, + * request id, and expiry stay exact in both cases. + * + * @param payload - Server-mode `authorization` or `deposit` payload + * @param channelId - Derived channel PDA + * @param requirements - Accepted requirements for this verify or settle + * @param proofBound - `"exact"` at verify; `"ceiling"` at settle + */ +export async function assertServerModeProof( + payload: Extract, + channelId: string, + requirements: PaymentRequirements, + proofBound: ProofAmountBound = "exact", +): Promise { + const authorization = payload.authorization; + if (!authorization) throw new Error(`${BatchError.VOUCHER_SIGNATURE}: payer proof missing`); + const charged = parseU64(requirements.amount, "amount"); + const authorized = parseU64(authorization.authorizedAmount, "authorizedAmount"); + if ( + authorization.channelId !== channelId || + authorization.payer !== payload.channelConfig.payer || + !proofCoversCharge(charged, authorized, proofBound) || + typeof authorization.requestId !== "string" || + authorization.requestId.length === 0 || + !(await verifyBatchAuthorization(authorization, payload.channelConfig.payerAuthorizer)) + ) { + throw new Error(`${BatchError.VOUCHER_SIGNATURE}: invalid payer proof`); + } +} + +/** + * Whether a metered charge is inside the payer proof. + * + * @param charged - Amount the request is verifying or settling + * @param authorized - Amount the payer proof signed + * @param bound - Exact match at verify, ceiling at settle + * @returns Whether the charge is allowed by the proof + */ +function proofCoversCharge(charged: bigint, authorized: bigint, bound: ProofAmountBound): boolean { + switch (bound) { + case "exact": + return charged === authorized; + case "ceiling": + return charged <= authorized; + default: { + const unexpected: never = bound; + return unexpected; + } + } +} + +/** + * Resolve voucher mode from the requirements or from the payload. + * + * @param config - Channel the payload names + * @param extra - Requirements extra from the payment or the redemption worker + * @param binding - `"requirements"` matches the payload to the requirements; `"payload"` follows `channelConfig` + * @returns The voucher mode those terms should enforce + */ +export function voucherSignerFor( + config: BatchChannelConfig, + extra: Record, + binding: VoucherModeBinding, +): "client" | "server" { + switch (binding) { + case "payload": { + const voucherSigner = config.voucherSigner ?? "client"; + if (voucherSigner !== "client" && voucherSigner !== "server") { + throw new Error(BatchError.CHANNEL_STATE); + } + if ( + voucherSigner === "server" && + (typeof extra.operator !== "string" || config.payerAuthorizer !== extra.operator) + ) { + throw new Error(BatchError.CHANNEL_STATE); + } + return voucherSigner; + } + case "requirements": { + const voucherSigner = extra.voucherSigner ?? "client"; + const operator = extra.operator; + if (voucherSigner !== "client" && voucherSigner !== "server") { + throw new Error(BatchError.CHANNEL_STATE); + } + if ( + (voucherSigner === "server" && + (typeof operator !== "string" || config.payerAuthorizer !== operator)) || + (voucherSigner === "client" && operator !== undefined) || + (config.voucherSigner ?? "client") !== voucherSigner + ) { + throw new Error(BatchError.CHANNEL_STATE); + } + return voucherSigner; + } + default: { + const unexpected: never = binding; + throw new Error(`${BatchError.CHANNEL_STATE}: ${String(unexpected)}`); + } + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/index.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/index.ts new file mode 100644 index 0000000000..1dbaa64f2e --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/index.ts @@ -0,0 +1,25 @@ +/** + * SVM `batch-settlement` scheme: high-throughput channel payments. The client + * deposits once into a long-lived payment channel and signs cumulative Ed25519 + * vouchers verified off-chain; the operator redeems them on-chain in batches. + * + * The top-level export is the client scheme (mirroring `upto`); the server and + * facilitator implementations are reached via the `./batch-settlement/server` + * and `./batch-settlement/facilitator` subpaths. + */ + +export { BatchSvmScheme } from "./client/scheme"; +export { + encodeBatchAuthorizationMessage, + signBatchAuthorization, + verifyBatchAuthorization, +} from "./authorization"; +export { + encodeCloseAuthorizationDigest, + signCloseAuthorization, + verifyCloseAuthorization, + type CloseAuthorizationBinding, +} from "./closeAuthorization"; +export * from "./types"; +export { BatchError } from "./errors"; +export type { BatchErrorReason } from "./errors"; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/receiverBinding.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/receiverBinding.ts new file mode 100644 index 0000000000..541f0cccdb --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/receiverBinding.ts @@ -0,0 +1,97 @@ +/** + * Receiver-authorizer binding carried by the canonical batch-settlement open. + * + * The client appends a Memo naming the challenged receiver authorizer. The + * payer signature covers it, so a lost row is reconstructable in principle. + */ + +import { + getBase64Codec, + getCompiledTransactionMessageDecoder, + getTransactionDecoder, + isAddress, +} from "@solana/kit"; + +import { MEMO_PROGRAM_ADDRESS } from "../constants"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../payment-channels/onchain"; +import { OPEN_DISCRIMINATOR } from "../payment-channels/generated/instructions/open"; + +export const RECEIVER_BINDING_MEMO_PREFIX = "x402:batch-settlement:svm:rcvauth:v1:"; + +/** `channel` is the sixth account of the canonical payment-channels `open`. */ +const OPEN_CHANNEL_ACCOUNT_INDEX = 5; + +/** + * Encode the binding memo text for a receiver authorizer. + * + * @param authorizer - Receiver-authorizer public key (base58) + * @returns The Memo instruction text + */ +export function encodeReceiverBindingMemo(authorizer: string): string { + return `${RECEIVER_BINDING_MEMO_PREFIX}${authorizer}`; +} + +/** + * Parse a binding memo back into its receiver-authorizer key. + * + * @param memo - Decoded Memo instruction text + * @returns The key, or undefined when the memo is not a well-formed binding + */ +export function parseReceiverBindingMemo(memo: string): string | undefined { + if (!memo.startsWith(RECEIVER_BINDING_MEMO_PREFIX)) return undefined; + const authorizer = memo.slice(RECEIVER_BINDING_MEMO_PREFIX.length); + return isAddress(authorizer) ? authorizer : undefined; +} + +/** + * Read the receiver authorizer a canonical open bound into `channelId`. + * + * The payer's signature covers the memo, and the open-slot and PDA rules + * allow only one successful open per channel. Zero binding memos, or more + * than one, is not a binding. + * + * @param wireTx - Base64 wire transaction + * @param channelId - Channel PDA the open must create + * @returns The bound key, or undefined when the transaction is not that open + */ +export function readReceiverBindingFromOpen(wireTx: string, channelId: string): string | undefined { + let message: CompiledMessage; + try { + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(wireTx)); + message = getCompiledTransactionMessageDecoder().decode( + decoded.messageBytes, + ) as unknown as CompiledMessage; + } catch { + return undefined; + } + if (message.addressTableLookups && message.addressTableLookups.length > 0) return undefined; + + const opens = message.instructions.filter(instruction => { + const program = message.staticAccounts[instruction.programAddressIndex]; + return program === PAYMENT_CHANNELS_PROGRAM_ID && instruction.data?.[0] === OPEN_DISCRIMINATOR; + }); + const open = opens.length === 1 ? opens[0] : undefined; + if (!open) return undefined; + const channelAccount = + message.staticAccounts[open.accountIndices?.[OPEN_CHANNEL_ACCOUNT_INDEX] ?? -1]; + if (channelAccount !== channelId) return undefined; + + const bindings: string[] = []; + for (const instruction of message.instructions) { + if (message.staticAccounts[instruction.programAddressIndex] !== MEMO_PROGRAM_ADDRESS) continue; + if (!instruction.data) continue; + const parsed = parseReceiverBindingMemo(new TextDecoder().decode(instruction.data)); + if (parsed !== undefined) bindings.push(parsed); + } + return bindings.length === 1 ? bindings[0] : undefined; +} + +type CompiledMessage = { + addressTableLookups?: readonly unknown[]; + instructions: readonly { + accountIndices?: readonly number[]; + data?: Uint8Array | undefined; + programAddressIndex: number; + }[]; + staticAccounts: readonly string[]; +}; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/channelManager.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/channelManager.ts new file mode 100644 index 0000000000..7f5c30fd7f --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/channelManager.ts @@ -0,0 +1,606 @@ +/** + * Autonomous redemption for the channels a resource server has served. + * + * Vouchers accumulate offchain and are worth nothing until they are claimed, + * so a server that never redeems forfeits everything it earned the moment a + * payer forces a close — the grace period is the whole window. This drives + * that redemption on an interval, out of the request path. + */ + +import { address, type MessagePartialSigner } from "@solana/kit"; +import { getChannelDecoder } from "../../payment-channels/generated/accounts/channel"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../payment-channels/onchain"; +import { createRpcClient } from "../../utils"; +import type { PaymentRequirements, SettleResponse } from "@x402/core/types"; + +import { signCloseAuthorization } from "../closeAuthorization"; +import { BatchError } from "../errors"; +import { BATCH_SETTLEMENT_SCHEME, type BatchSealPayload } from "../types"; +import type { ChannelState, ChannelStore } from "./types"; + +/** + * The spec packs no more than four channels into one claim transaction, and a + * full batch is never silently truncated. + */ +const MAX_CHANNELS_PER_BATCH = 4; + +/** Submits a server-authored redemption payload; normally a facilitator. */ +export type RedemptionSettler = ( + payload: { x402Version: number; payload: unknown; accepted: PaymentRequirements }, + requirements: PaymentRequirements, +) => Promise; + +/** Outcome of a successful onchain claim batch. */ +export interface ClaimResult { + vouchers: number; + transaction: string; +} + +/** Outcome of a successful distribute batch that pays `payTo`. */ +export interface SettleResult { + transaction: string; +} + +/** Outcome of a successful `seal` on a channel the payer is closing. */ +export interface SealResult { + channel: string; + transaction: string; +} + +export interface BatchChannelManagerConfig { + /** The server's channel state, holding the vouchers to redeem. */ + store: ChannelStore; + /** How redemption payloads reach the chain. */ + settle: RedemptionSettler; + /** + * Terms the channels were opened against — network, asset, `payTo` and + * `extra.feePayer`. Redemption is authored against these, so they must be + * the ones the server advertises. + */ + requirements: PaymentRequirements; + /** Channels per claim or distribute transaction. Defaults to, and is capped at, the spec's four. */ + maxChannelsPerBatch?: number | undefined; + /** + * RPC endpoint for confirmed watermark reads. Omit to use the public + * endpoint for `requirements.network`. + */ + rpcUrl?: string | undefined; + /** Optional confirmed channel reader for custom transports; never estimate from the response amount. */ + readPayoutWatermark?: ((channelId: string) => Promise) | undefined; + /** + * Optional confirmed reader of the onchain `settled` watermark, used when a + * claim response carries no per-channel confirmation (spec 4.5 defines only + * `success`, `transaction`, `network` and `amount` for a claim). + */ + readSettledWatermark?: ((channelId: string) => Promise) | undefined; + /** Fires after a successful onchain claim batch. */ + onClaim?: ((result: ClaimResult) => void) | undefined; + /** Fires after a successful distribute (`settle`) batch. */ + onSettle?: ((result: SettleResult) => void) | undefined; + /** Fires after a successful `seal` on a closing channel. */ + onSeal?: ((result: SealResult) => void) | undefined; + /** Reports a pass that failed, so an operator can see it. */ + onError?: ((error: unknown) => void) | undefined; + /** + * Receiver-authorizer key advertised as `extra.receiverAuthorizer`. For a + * channel the payer is closing, the worker signs a `CloseAuthorization` with + * it and submits a `seal`, so vouchers above the onchain watermark are + * collected inside the grace period instead of forfeited. Omit it when the + * facilitator authenticates the close itself. + */ + receiverAuthorizer?: MessagePartialSigner | undefined; +} + +/** What one redemption pass moved. */ +export interface RedemptionResult { + claimed: string[]; + distributed: string[]; + /** Closing channels finalized with the latest voucher through `seal`. */ + sealed: string[]; +} + +/** + * Claims accumulated vouchers and distributes what they settle. + * + * Both halves are separate onchain steps: `claim` advances the settled + * watermark from a stored voucher, and `distribute` pays the newly settled + * delta to `payTo`. A pass does the first for every channel that has an + * unclaimed voucher, then the second for every channel holding an + * undistributed balance. + */ +export class BatchChannelManager { + private timer: ReturnType | undefined; + private passInFlight: Promise = Promise.resolve(); + private running = false; + private readonly graceElapsedReported = new Set(); + + /** + * Build a worker over a store and a way to submit redemption payloads. + * + * @param config - Store, settler and the terms to redeem against + */ + constructor(private readonly config: BatchChannelManagerConfig) {} + + /** + * Run one redemption pass: claim what has vouchers, pay out what settles. + * + * Safe to call from a cron instead of using {@link start}; passes are + * serialized either way, so a slow pass cannot overlap the next and submit + * the same claim twice. + * + * @returns The channels claimed and distributed + */ + async redeem(): Promise { + const pass = this.passInFlight.then( + () => this.runPass(), + () => this.runPass(), + ); + this.passInFlight = pass.catch(() => undefined); + return pass; + } + + /** + * Redeem every `intervalSecs`. + * + * Claim well inside the forced-close grace period: a voucher still unclaimed + * when a close completes is value the server gives back to the payer. + * + * @param intervalSecs - Seconds between passes + */ + start(intervalSecs: number): void { + if (this.running) return; + this.running = true; + this.timer = setInterval(() => { + void this.redeem().catch(error => this.config.onError?.(error)); + }, intervalSecs * 1_000); + } + + /** + * Stop the interval and wait for a pass already under way. + * + * @param opts - Stop options. + * @param opts.flush - When true, run one final {@link redeem} before returning. + * @returns Resolves when the interval is stopped (and flush work completes, if requested). + */ + async stop(opts?: { flush?: boolean }): Promise { + this.running = false; + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + if (opts?.flush) { + await this.redeem().catch(error => this.config.onError?.(error)); + } + await this.passInFlight; + } + + /** + * One pass: claim, then distribute against freshly read state. + * + * @returns The channels claimed and distributed + */ + private async runPass(): Promise { + if (typeof this.config.store.list !== "function") { + throw new Error("BatchChannelManager requires a channel store that can list its channels"); + } + const list = this.config.store.list.bind(this.config.store); + const { claimed, sealed } = await this.claim(await list()); + // Re-read before paying out: a claim in this same pass just advanced the + // watermarks that decide what there is to distribute, so the snapshot the + // pass opened with is already stale. + const distributed = await this.distribute(await list()); + return { claimed, distributed, sealed }; + } + + /** + * Advance the onchain settled watermark from each stored voucher. + * + * @param channels - Channels to consider claiming + * @returns The channels whose claim landed + */ + private async claim(channels: ChannelState[]): Promise<{ claimed: string[]; sealed: string[] }> { + const unclaimed = channels.filter( + channel => + channel.highestVoucherSignature !== undefined && + channel.signedMaxClaimable > channel.settled, + ); + const claimable = unclaimed.filter(channel => channel.status === "open"); + const closing = unclaimed.filter(channel => channel.status === "closing"); + const result = { claimed: [] as string[], sealed: [] as string[] }; + for (const batch of chunk(claimable, this.batchSize())) { + await this.claimBatch(batch, result); + } + for (const channel of closing) { + if (await this.seal(channel)) result.sealed.push(channel.channelId); + } + return result; + } + + /** + * Claim one batch, or fall back to `seal` for a channel the payer is closing. + * + * @param batch - Channels packed into one claim transaction + * @param result - Accumulates the pass outcome + * @param result.claimed - Channels whose claim landed + * @param result.sealed - Closing channels finalized through `seal` + */ + private async claimBatch( + batch: ChannelState[], + result: { claimed: string[]; sealed: string[] }, + ): Promise { + const response = await this.config.settle( + { + accepted: this.config.requirements, + payload: { + claims: batch.map(channel => ({ + channelConfig: channel.channelConfig, + channelId: channel.channelId, + voucher: { + channelId: channel.channelId, + expiresAt: channel.highestVoucherExpiresAt ?? 0, + maxClaimableAmount: channel.signedMaxClaimable.toString(), + signature: channel.highestVoucherSignature!, + }, + })), + type: "claim", + }, + x402Version: 2, + }, + this.config.requirements, + ); + if (!response.success) { + if (response.errorReason === BatchError.CHANNEL_CLOSING) { + // The payer started a forced close on at least one channel in the + // batch, so program `settle` is no longer available for it. Claim the + // others one by one and finalize the closing one with its latest + // voucher through `seal` while the grace period still allows it. + if (batch.length > 1) { + for (const channel of batch) await this.claimBatch([channel], result); + return; + } + const channel = batch[0]!; + if (await this.seal(channel)) result.sealed.push(channel.channelId); + return; + } + // A batch that did not land leaves its channels for the next pass; + // the watermark is monotonic, so a repeat is harmless. + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} claim failed: ${response.errorReason ?? "unknown"}`), + ); + return; + } + if (response.network !== this.config.requirements.network) { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} claim response bound to another network`), + ); + return; + } + { + // The spec's claim response is just `success`/`transaction`/`network`/ + // `amount`; the reference facilitator adds `extra.accepts[]` with each + // channel's confirmed watermark. Use it when present, otherwise read the + // watermark from the chain so any conforming facilitator works. + const accepts = response.extra?.accepts; + if (accepts !== undefined) { + if ( + !Array.isArray(accepts) || + accepts.length !== batch.length || + batch.some(channel => { + const matches = accepts.filter( + item => + typeof item === "object" && + item !== null && + "channelId" in item && + item.channelId === channel.channelId, + ); + return ( + matches.length !== 1 || + !("totalClaimed" in matches[0]!) || + matches[0]!.totalClaimed !== channel.signedMaxClaimable.toString() + ); + }) + ) { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} claim missing confirmed settled watermark`), + ); + return; + } + } + let claimedInBatch = 0; + for (const channel of batch) { + let settled = channel.signedMaxClaimable; + if (accepts === undefined) { + try { + const observed = await this.readSettledWatermark(channel.channelId); + if (observed === undefined || observed < channel.signedMaxClaimable) { + throw new Error("confirmed settled watermark unavailable or behind the claim"); + } + settled = observed; + } catch (error) { + // Leave the voucher claimable for the next pass; `settle` is + // monotonic, so a repeat cannot advance the watermark twice. + this.config.onError?.(error); + continue; + } + } + await this.record(channel.channelId, state => ({ + ...state, + onchainSyncedAt: Date.now(), + settled: state.settled > settled ? state.settled : settled, + })); + result.claimed.push(channel.channelId); + claimedInBatch++; + } + if (claimedInBatch > 0) { + this.config.onClaim?.({ + transaction: response.transaction ?? "", + vouchers: claimedInBatch, + }); + } + } + } + + /** + * Finalize a channel the payer is closing with the server's latest voucher. + * + * Program `settle` is unavailable once a channel is `Closing`; only the + * facilitator, as channel payee, can apply a final voucher through + * `settle_and_seal` during the grace period. The server proves it authored + * the request with a `CloseAuthorization` from its receiver authorizer. + * + * @param channel - The closing channel and its latest voucher + * @returns Whether the seal landed + */ + private async seal(channel: ChannelState): Promise { + const closeRequestedAt = channel.closeRequestedAt ?? 0; + const graceElapsed = + closeRequestedAt > 0 && + Math.floor(Date.now() / 1000) >= closeRequestedAt + channel.withdrawDelay; + if (graceElapsed) { + if (!this.graceElapsedReported.has(channel.channelId)) { + this.graceElapsedReported.add(channel.channelId); + this.config.onError?.( + new Error( + `${BATCH_SETTLEMENT_SCHEME} channel ${channel.channelId} grace period elapsed: ` + + `voucher value above the onchain watermark can no longer be sealed`, + ), + ); + } + return false; + } + // Whatever happens next, the payer has started a forced close: stop + // serving paid requests against this channel. + await this.record(channel.channelId, state => + state.status === "open" ? { ...state, status: "closing" } : state, + ); + const feePayer = this.config.requirements.extra?.feePayer; + if (typeof feePayer !== "string") { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} seal requires requirements.extra.feePayer`), + ); + return false; + } + const { network, maxTimeoutSeconds } = this.config.requirements; + const expiresAt = channel.highestVoucherExpiresAt ?? 0; + const authorizer = this.config.receiverAuthorizer; + const closeAuthorization = authorizer + ? await signCloseAuthorization(authorizer, { + channelId: channel.channelId, + feePayer, + maxClaimableAmount: channel.signedMaxClaimable, + network, + validBefore: Math.floor(Date.now() / 1000) + maxTimeoutSeconds, + voucherExpiresAt: BigInt(expiresAt), + }) + : undefined; + const payload: BatchSealPayload = { + channelConfig: channel.channelConfig, + channelId: channel.channelId, + type: "seal", + voucher: { + channelId: channel.channelId, + expiresAt, + maxClaimableAmount: channel.signedMaxClaimable.toString(), + signature: channel.highestVoucherSignature!, + }, + ...(closeAuthorization ? { closeAuthorization } : {}), + }; + const response = await this.config.settle( + { accepted: this.config.requirements, payload, x402Version: 2 }, + this.config.requirements, + ); + if (!response.success || response.network !== network) { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} seal failed: ${response.errorReason ?? "unknown"}`), + ); + return false; + } + const final = channel.signedMaxClaimable; + await this.record(channel.channelId, state => ({ + ...state, + onchainSyncedAt: Date.now(), + payoutWatermark: state.payoutWatermark > final ? state.payoutWatermark : final, + settled: state.settled > final ? state.settled : final, + status: "distributed", + })); + this.config.onSeal?.({ + channel: channel.channelId, + transaction: response.transaction ?? "", + }); + return true; + } + + /** + * Pay each newly settled delta to `payTo`. + * + * @param channels - Channels to consider paying out + * @returns The channels whose distribution landed + */ + private async distribute(channels: ChannelState[]): Promise { + const payable = channels.filter( + channel => channel.status === "open" && channel.settled > channel.payoutWatermark, + ); + const distributed: string[] = []; + for (const batch of chunk(payable, this.batchSize())) { + const response = await this.config.settle( + { + accepted: this.config.requirements, + payload: { + channels: batch.map(channel => ({ + channelConfig: channel.channelConfig, + channelId: channel.channelId, + })), + type: "settle", + }, + x402Version: 2, + }, + this.config.requirements, + ); + if (!response.success) { + this.config.onError?.( + new Error( + `${BATCH_SETTLEMENT_SCHEME} distribute failed: ${response.errorReason ?? "unknown"}`, + ), + ); + continue; + } + if (response.network !== this.config.requirements.network) { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} distribute response bound to another network`), + ); + continue; + } + // `extra.channels[]` is the reference facilitator's addition, not a + // spec field; when present it must name exactly this batch. The paid + // watermark is always reconciled from the chain below either way. + const channels = response.extra?.channels; + if ( + channels !== undefined && + (!Array.isArray(channels) || + channels.length !== batch.length || + new Set(channels).size !== channels.length || + batch.some(channel => !channels.includes(channel.channelId))) + ) { + this.config.onError?.( + new Error(`${BATCH_SETTLEMENT_SCHEME} distribute response channel mismatch`), + ); + continue; + } + let distributedInBatch = 0; + for (const channel of batch) { + try { + // This response may recover an earlier sweep. A channel ID and a + // successful signature do not prove that today's full claim was paid. + const paid = await this.readPayoutWatermark(channel.channelId); + if (paid === undefined || paid < 0n || paid > channel.deposit) + throw new Error("confirmed payout watermark unavailable or invalid"); + await this.record(channel.channelId, state => ({ + ...state, + onchainSyncedAt: Date.now(), + payoutWatermark: state.payoutWatermark > paid ? state.payoutWatermark : paid, + })); + if (paid >= channel.settled) { + distributed.push(channel.channelId); + distributedInBatch++; + } + } catch (error) { + // Leave the balance payable for the next pass, including after restart. + this.config.onError?.(error); + } + } + if (distributedInBatch > 0) { + this.config.onSettle?.({ transaction: response.transaction ?? "" }); + } + } + return distributed; + } + + /** + * Read a conservative paid watermark; stale reads delay bookkeeping, never advance it too far. + * + * @param channelId - Channel whose paid state is being reconciled + * @returns Observed payout watermark, or undefined if the account is absent + */ + private async readPayoutWatermark(channelId: string): Promise { + if (this.config.readPayoutWatermark) return this.config.readPayoutWatermark(channelId); + return (await this.readSettlement(channelId))?.payoutWatermark; + } + + /** + * Read the confirmed onchain `settled` watermark for a channel. + * + * @param channelId - Channel whose claimed state is being reconciled + * @returns Observed settled watermark, or undefined if the account is absent + */ + private async readSettledWatermark(channelId: string): Promise { + if (this.config.readSettledWatermark) return this.config.readSettledWatermark(channelId); + return (await this.readSettlement(channelId))?.settled; + } + + /** + * Decode the confirmed settlement fields of a channel account. + * + * @param channelId - Channel account to read + * @returns The channel's settlement fields, or undefined if the account is absent + */ + private async readSettlement( + channelId: string, + ): Promise<{ settled: bigint; payoutWatermark: bigint } | undefined> { + const rpc = createRpcClient(this.config.requirements.network, this.config.rpcUrl); + const account = await rpc + .getAccountInfo(address(channelId), { commitment: "confirmed", encoding: "base64" }) + .send(); + if (!account.value) return undefined; + if (account.value.owner !== PAYMENT_CHANNELS_PROGRAM_ID) + throw new Error("unexpected channel account owner"); + const { settled, payoutWatermark } = getChannelDecoder().decode( + Buffer.from(account.value.data[0], "base64"), + ).settlement; + return { settled, payoutWatermark }; + } + + /** + * Fold a confirmed redemption into the stored channel. + * + * @param channelId - Channel to update + * @param updater - Applies the confirmed advance + */ + private async record( + channelId: string, + updater: (state: ChannelState) => ChannelState, + ): Promise { + await this.config.store.update(channelId, current => { + if (!current) throw new Error(`channel ${channelId} vanished mid-redemption`); + return updater(current); + }); + } + + /** + * How many channels one redemption transaction carries. + * + * @returns Channels to pack into one redemption transaction + */ + private batchSize(): number { + // Clamp to the spec's ceiling: a `claims[]` or `channels[]` array longer + // than four is not a valid request (spec 4.5). + return Math.min( + MAX_CHANNELS_PER_BATCH, + Math.max(1, this.config.maxChannelsPerBatch ?? MAX_CHANNELS_PER_BATCH), + ); + } +} + +/** + * Split `items` into runs of at most `size`. + * + * @param items - What to split + * @param size - Longest run to produce + * @returns The runs, in order + */ +function chunk(items: T[], size: number): T[][] { + const batches: T[][] = []; + for (let index = 0; index < items.length; index += size) { + batches.push(items.slice(index, index + size)); + } + return batches; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/constants.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/constants.ts new file mode 100644 index 0000000000..feb6fdbd6f --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/constants.ts @@ -0,0 +1,11 @@ +/** Error when a reservation conflicts with work already in flight on the channel. */ +export const CHANNEL_BUSY = "duplicate_settlement"; + +/** How many request amounts of escrow to hint when the server does not set `minDeposit`. */ +export const DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER = 10n; + +/** + * Server-signed escrow is what the operator could take, so the published + * min-deposit hint stays near the client-side minimum. + */ +export const DEFAULT_SERVER_SIGNED_MIN_DEPOSIT_MULTIPLIER = 3n; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/index.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/index.ts new file mode 100644 index 0000000000..aacf939040 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/index.ts @@ -0,0 +1,15 @@ +export { BatchSvmScheme } from "./scheme"; +export type { BatchSvmServerConfig } from "./scheme"; +export { MemoryChannelStore } from "./storage"; +export type { ChannelState, ChannelStore } from "./types"; +export { MemoryBatchOperationStore } from "./operationStore"; +export type { BatchOperation, BatchOperationStore } from "./operationStore"; +export { + BatchChannelManager, + type BatchChannelManagerConfig, + type ClaimResult, + type RedemptionResult, + type RedemptionSettler, + type SealResult, + type SettleResult, +} from "./channelManager"; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/operationStore.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/operationStore.ts new file mode 100644 index 0000000000..4e0cb6735d --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/operationStore.ts @@ -0,0 +1,124 @@ +/** Single-use server-mode request records, separate from channel accounting. */ + +export type BatchOperation = + | { + status: "reserved"; + channelId: string; + requestId: string; + ceiling: bigint; + } + | { + status: "completed"; + channelId: string; + requestId: string; + ceiling: bigint; + actual: bigint; + cumulative: bigint; + }; + +export interface BatchOperationStore { + /** Fetch a reserved or completed request operation. */ + get(channelId: string, requestId: string): Promise; + /** Atomically create a request reservation unless the operation already exists. */ + reserve( + channelId: string, + requestId: string, + ceiling: bigint, + ): Promise<{ created: boolean; operation: BatchOperation }>; + /** Atomically mark a reservation completed so the request cannot be reused. */ + complete(operation: Extract): Promise; + /** End failed or canceled work while retaining the consumed request id. */ + release(channelId: string, requestId: string): Promise; +} + +/** In-memory operation store used by the reference implementation. */ +export class MemoryBatchOperationStore implements BatchOperationStore { + private readonly operations = new Map(); + private readonly locks = new Map>(); + + /** @inheritdoc */ + get(channelId: string, requestId: string): Promise { + return Promise.resolve(this.operations.get(operationKey(channelId, requestId))); + } + + /** @inheritdoc */ + reserve( + channelId: string, + requestId: string, + ceiling: bigint, + ): Promise<{ created: boolean; operation: BatchOperation }> { + return this.withLock(channelId, requestId, () => { + const key = operationKey(channelId, requestId); + const existing = this.operations.get(key); + if (existing && existing.ceiling !== ceiling) { + throw new Error("batch operation ceiling changed for a request id"); + } + if (existing) return { created: false, operation: existing }; + const operation: BatchOperation = { + status: "reserved", + channelId, + requestId, + ceiling, + }; + this.operations.set(key, operation); + return { created: true, operation }; + }); + } + + /** @inheritdoc */ + complete(operation: Extract): Promise { + return this.withLock(operation.channelId, operation.requestId, () => { + const key = operationKey(operation.channelId, operation.requestId); + const existing = this.operations.get(key); + if (!existing || existing.status !== "reserved" || existing.ceiling !== operation.ceiling) { + throw new Error("batch operation reservation changed"); + } + this.operations.set(key, operation); + }); + } + + /** @inheritdoc */ + release(channelId: string, requestId: string): Promise { + return this.withLock(channelId, requestId, () => { + // The capacity reservation is released by the channel store. Keep this + // record as a tombstone so a failed HTTP request cannot reuse its id. + }); + } + + /** + * Run one operation-key mutation at a time. + * + * @param channelId - Channel identifier + * @param requestId - Request identifier + * @param operation - Mutation to serialize + * @returns The mutation result + */ + private async withLock( + channelId: string, + requestId: string, + operation: () => T | Promise, + ): Promise { + const key = operationKey(channelId, requestId); + const prior = this.locks.get(key) ?? Promise.resolve(); + const run = prior.then(operation); + this.locks.set( + key, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; + } +} + +/** + * Build the collision-free in-memory key for one request operation. + * + * @param channelId - Channel identifier + * @param requestId - Request identifier + * @returns Internal map key + */ +function operationKey(channelId: string, requestId: string): string { + return `${channelId}\u0000${requestId}`; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/scheme.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/scheme.ts new file mode 100644 index 0000000000..f5d55d8019 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/scheme.ts @@ -0,0 +1,1716 @@ +import type { + FacilitatorClient, + SettleContext, + SettleFailureContext, + SettleResultContext, + SkipHandlerDirective, + VerifiedPaymentCanceledContext, + VerifyContext, + VerifyFailureContext, + VerifyResultContext, +} from "@x402/core/server"; +import type { + AssetAmount, + MoneyParser, + SchemePaymentRequiredContext, + Network, + PaymentFlowConfig, + PaymentPayload, + PaymentRequirements, + Price, + SchemeNetworkServer, + SchemeServerHooks, + SettleResponse, + SupportedKind, + VerifyResponse, +} from "@x402/core/types"; +import type { DeepReadonly } from "@x402/core/types"; +import { convertToTokenAmount, parseMoney } from "@x402/core/utils"; +import type { MessagePartialSigner } from "@solana/kit"; + +import { TOKEN_2022_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS } from "../../constants"; +import { findDefaultAsset, getDefaultAsset } from "../../defaultAssets"; +import { + encodeVoucherMessageBytes, + signVoucher, + verifyVoucherSignature, +} from "../../payment-channels/voucher"; +import { + findPaymentChannelPda, + parseU64, + verifyOpenTransaction, +} from "../../payment-channels/open"; +import { getStablecoinTokenProgram, validateSvmAddress } from "../../utils"; +import { + CLIENT_VOUCHER_EXPIRES_AT, + FULL_SPLIT_BPS, + MAX_WITHDRAW_DELAY, + MIN_WITHDRAW_DELAY, +} from "../constants"; +import { BatchError } from "../errors"; +import { verifyBatchAuthorization } from "../authorization"; +import { signCloseAuthorization } from "../closeAuthorization"; +import { encodeReceiverBindingMemo } from "../receiverBinding"; +import type { + BatchAuthorization, + BatchChannelConfig, + BatchPayload, + BatchProof, + BatchVoucher, + CloseAuthorization, +} from "../types"; +import { BATCH_SETTLEMENT_SCHEME, isBatchPayload, proofOf } from "../types"; +import { type BatchOperationStore, MemoryBatchOperationStore } from "./operationStore"; +import { BatchChannelManager, type BatchChannelManagerConfig } from "./channelManager"; +import { MemoryChannelStore } from "./storage"; +import { + CHANNEL_BUSY, + DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER, + DEFAULT_SERVER_SIGNED_MIN_DEPOSIT_MULTIPLIER, +} from "./constants"; +import type { ChannelState, ChannelStore, RequestContext, VerifiedChannelState } from "./types"; + +export interface BatchSvmServerConfig { + withdrawDelay?: number | undefined; + /** + * Receiver-authorizer signer. Advertised as `extra.receiverAuthorizer`, + * bound to each channel at open, and used to sign the `CloseAuthorization`s + * that cooperative refunds and seals of closing channels require. + * Omit it to delegate those closes to a facilitator that advertises its own + * `receiverAuthorizer`. + */ + receiverAuthorizer?: MessagePartialSigner | undefined; + /** Called when the facilitator reports a paid request's channel as closing, so the host can run a redemption pass. */ + onChannelClosing?: ((channelId: string) => void) | undefined; + store?: ChannelStore | undefined; + /** Maximum age of onchain state used to verify vouchers locally. */ + onchainStateTtlMs?: number | undefined; + /** Reject deposits below the announced `extra.minDeposit` hint. Defaults to false. */ + enforceMinDeposit?: boolean | undefined; + operationStore?: BatchOperationStore | undefined; + /** Operator key used to sign cumulative vouchers after successful requests. */ + operator?: MessagePartialSigner | undefined; +} + +/** + * SVM resource-server implementation for `batch-settlement`. + * + * The server, not the facilitator, owns the offchain voucher watermark. Hooks + * reserve channel capacity during verification, leave the reservation + * unchanged while the handler runs, and commit the measured charge only during + * the after-handler settle phase. + */ +export class BatchSvmScheme implements SchemeNetworkServer { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + readonly defaultAssetTransferMethod = "channel"; + readonly paymentFlows = { + channel: { default: "authorization", supported: ["authorization"] }, + } as const satisfies Record; + readonly dynamicExtraFields = ["recentBlockhash", "recentSlot"]; + readonly schemeHooks: SchemeServerHooks; + + private readonly store: ChannelStore; + private readonly operationStore: BatchOperationStore; + private readonly requestContexts = new WeakMap, RequestContext>(); + private readonly settlementExtras = new WeakMap< + DeepReadonly, + Record + >(); + private moneyParsers: MoneyParser[] = []; + private reservationSequence = 0; + + /** + * Construct the server-side batch-settlement scheme. + * + * @param config - Channel store, operator, receiver authorizer, and hook options + */ + constructor(private readonly config: BatchSvmServerConfig) { + this.store = config.store ?? new MemoryChannelStore(); + this.operationStore = config.operationStore ?? new MemoryBatchOperationStore(); + this.schemeHooks = { + onBeforeVerify: ctx => this.beforeVerify(ctx), + onAfterVerify: ctx => this.afterVerify(ctx), + onBeforeSettle: ctx => this.beforeSettle(ctx), + onAfterSettle: ctx => this.afterSettle(ctx), + onVerifyFailure: ctx => this.onVerifyFailure(ctx), + onSettleFailure: ctx => this.onSettleFailure(ctx), + onVerifiedPaymentCanceled: ctx => this.onCanceled(ctx), + }; + } + + /** + * Attach the receiver authorizer's `CloseAuthorization` to a refund, so the + * facilitator can close the channel cooperatively with the accepted voucher. + * + * @param ctx - Settle context for the refund + * @returns The `closeAuthorization` field, or nothing for other payloads + */ + enrichSettlementPayload = async (ctx: SettleContext): Promise | void> => { + const raw = ctx.paymentPayload.payload; + if (!isBatchPayload(raw) || raw.type !== "refund") return; + const request = this.requestContexts.get(ctx.paymentPayload); + if (!request?.pendingId) throw new Error(CHANNEL_BUSY); + const feePayer = ctx.requirements.extra?.feePayer; + if (typeof feePayer !== "string") throw new Error(BatchError.FEE_PAYER_MISMATCH); + const serverMode = (raw.channelConfig.voucherSigner ?? "client") === "server"; + let closeAuthorization: CloseAuthorization | undefined; + let injectedVoucher: BatchVoucher | undefined; + await this.store.update(request.channelId, async current => { + if (!current?.reservations?.[request.pendingId!]) throw new Error(CHANNEL_BUSY); + const cumulative = current.chargedCumulativeAmount; + let voucher: BatchVoucher; + if (raw.voucher) { + if (BigInt(raw.voucher.maxClaimableAmount) !== cumulative) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + voucher = raw.voucher; + } else if (!serverMode) { + throw new Error(BatchError.VOUCHER_SIGNATURE); + } else if (current.highestVoucherSignature && cumulative > 0n) { + voucher = { + channelId: request.channelId, + expiresAt: current.highestVoucherExpiresAt ?? 0, + maxClaimableAmount: cumulative.toString(), + signature: current.highestVoucherSignature, + }; + } else if (cumulative === 0n) { + voucher = await this.signOperatorVoucher(request.channelId, 0n); + } else { + throw new Error(BatchError.VOUCHER_SIGNATURE); + } + if (serverMode) injectedVoucher = voucher; + if (this.config.receiverAuthorizer) { + closeAuthorization = await signCloseAuthorization(this.config.receiverAuthorizer, { + channelId: request.channelId, + feePayer, + maxClaimableAmount: cumulative, + network: ctx.requirements.network, + validBefore: Math.floor(Date.now() / 1000) + ctx.requirements.maxTimeoutSeconds, + voucherExpiresAt: BigInt(voucher.expiresAt), + }); + } + return current; + }); + return { + ...(injectedVoucher ? { voucher: injectedVoucher } : {}), + ...(closeAuthorization ? { closeAuthorization } : {}), + }; + }; + + enrichSettlementResponse = async ( + ctx: SettleResultContext, + ): Promise | void> => { + const extra = this.settlementExtras.get(ctx.paymentPayload); + this.settlementExtras.delete(ctx.paymentPayload); + return extra ? withoutExistingFields(extra, ctx.result.extra) : undefined; + }; + + /** + * Attach the corrective channel state a client needs to resynchronize after + * a cumulative-amount mismatch. + * + * The snapshot alone would be the server's unproven word for how much it has + * charged, so it travels with `voucherState`: the signature the client itself + * produced at that cumulative amount. A server with no accepted voucher — + * one that just rebuilt the record from onchain state — omits the proof, and + * the client resynchronizes from the chain instead. See spec section 4.6. + * + * @param ctx - Payment-required context for the failed request + * @returns The enriched requirements, or nothing when there is nothing to add + */ + enrichPaymentRequiredResponse = async ( + ctx: SchemePaymentRequiredContext, + ): Promise => { + if (ctx.error !== BatchError.CUMULATIVE_AMOUNT_MISMATCH) return; + const raw = ctx.paymentPayload?.payload; + if (!raw || !isBatchPayload(raw)) return; + // The mismatch is usually detected before any request context exists, so + // the channel comes from the payload — and only from one that validates, + // signature included. Otherwise naming a channel would be enough to read + // its state. + let channelId: string; + try { + channelId = await this.validatePayload(raw, ctx.paymentPayload!.accepted); + } catch { + return; + } + const state = await this.store.get(channelId); + if (!state) return; + const accept = ctx.requirements.find( + requirement => + requirement.scheme === BATCH_SETTLEMENT_SCHEME && + requirement.network === ctx.paymentPayload!.accepted.network, + ); + if (!accept) return; + accept.extra = { + ...accept.extra, + channelState: { + channelId: state.channelId, + balance: state.deposit.toString(), + totalClaimed: state.settled.toString(), + withdrawRequestedAt: state.closeRequestedAt ?? 0, + chargedCumulativeAmount: state.chargedCumulativeAmount.toString(), + }, + ...(state.highestVoucherSignature !== undefined + ? { + voucherState: { + signedMaxClaimable: state.signedMaxClaimable.toString(), + expiresAt: state.highestVoucherExpiresAt ?? 0, + signature: state.highestVoucherSignature, + }, + } + : {}), + }; + return ctx.requirements; + }; + + /** + * Expose the channel store for tests and redemption workers. + * + * @returns The configured or default in-memory channel store + */ + getChannelStore(): ChannelStore { + return this.store; + } + + /** + * Register a custom money parser in the parser chain (tried in order). + * + * @param parser - Custom function to convert an amount to an AssetAmount (or null to skip) + * @returns This instance for chaining + */ + registerMoneyParser(parser: MoneyParser): BatchSvmScheme { + this.moneyParsers.push(parser); + return this; + } + + /** + * Parse a price into an asset amount. AssetAmount inputs pass through; Money + * inputs are parsed to a decimal and run through the parser chain, falling + * back to the default stablecoin conversion. + * + * @param price - The price to parse + * @param network - The network to use + * @returns The parsed asset amount + */ + async parsePrice(price: Price, network: Network): Promise { + if (typeof price === "object" && price !== null && "amount" in price) { + if (!price.asset) { + throw new Error(`Asset address must be specified for AssetAmount on network ${network}`); + } + return { amount: price.amount, asset: price.asset, extra: price.extra || {} }; + } + const { amount, symbol } = parseMoney(price); + for (const parser of this.moneyParsers) { + const result = await parser(amount, network); + if (result !== null) return result; + } + return this.defaultMoneyConversion(amount, network, symbol); + } + + /** + * Fail server startup when the facilitator does not advertise a usable `feePayer`, + * or when this server delegates and the facilitator does not advertise a + * `receiverAuthorizer`. + * + * @param network - The network identifier being validated + * @param supportedKind - The facilitator's advertised kind for this scheme/network + * @param _ - Extensions advertised by the facilitator (unused) + * @returns A problem message when misconfigured, or void when valid + */ + validateFacilitatorSupport( + network: Network, + supportedKind: SupportedKind, + _: string[], + ): string | void { + const feePayer = supportedKind.extra?.feePayer; + if (typeof feePayer !== "string" || !validateSvmAddress(feePayer)) { + return ( + `facilitator does not advertise a valid feePayer for batch-settlement on ${network}; ` + + `a base58 Solana address is required` + ); + } + if (this.config.receiverAuthorizer) return; + + const advertised = supportedKind.extra?.receiverAuthorizer; + if (typeof advertised !== "string" || !validateSvmAddress(advertised)) { + return ( + `no receiverAuthorizer is configured and the facilitator does not advertise a ` + + `receiverAuthorizer on ${network}. Configure a receiverAuthorizer or use a ` + + `facilitator that advertises one.` + ); + } + } + + /** + * Fold facilitator extras into the accept: `feePayer`, `tokenProgram`, + * `withdrawDelay`, `minDeposit`, `receiverAuthorizer`, and optional + * server-signed `operator` / `voucherSigner`. + * + * @param paymentRequirements - Route requirements before enrichment + * @param supportedKind - Facilitator `/supported` kind for this network + * @param supportedKind.x402Version - The x402 version + * @param supportedKind.scheme - The payment scheme + * @param supportedKind.network - The network identifier + * @param supportedKind.extra - Facilitator extra (`feePayer`, optional `receiverAuthorizer`) + * @param extensionKeys - Extension keys on the accept (unused) + * @returns Enriched payment requirements for the 402 challenge + */ + enhancePaymentRequirements( + paymentRequirements: PaymentRequirements, + supportedKind: { + x402Version: number; + scheme: string; + network: Network; + extra?: Record; + }, + extensionKeys: string[], + ): Promise { + void extensionKeys; + const withdrawDelay = + this.config.withdrawDelay ?? + Math.max(MIN_WITHDRAW_DELAY, paymentRequirements.maxTimeoutSeconds); + if (withdrawDelay > MAX_WITHDRAW_DELAY) { + throw new Error(BatchError.WITHDRAW_DELAY_OUT_OF_RANGE); + } + const serverSigned = this.isServerSigned(paymentRequirements); + // A route may pin itself to client mode with `extra.voucherSigner: + // "client"` even when an operator is configured, so one route can offer + // both a server-signed accept (metered) and a client-signed accept (the + // ceiling as a fixed price) and clients that do not trust the operator + // still have a way to pay. + const { operator: _routeOperator, ...routeExtra } = paymentRequirements.extra ?? {}; + void _routeOperator; + const advertised = supportedKind.extra?.receiverAuthorizer; + const receiverAuthorizer = + this.config.receiverAuthorizer?.address ?? + (typeof advertised === "string" ? advertised : undefined); + if (!receiverAuthorizer || !validateSvmAddress(receiverAuthorizer)) { + throw new Error("Payment requirements must include a valid extra.receiverAuthorizer"); + } + return Promise.resolve({ + ...paymentRequirements, + extra: { + ...routeExtra, + ...supportedKind.extra, + tokenProgram: getStablecoinTokenProgram( + paymentRequirements.asset, + paymentRequirements.network, + ), + withdrawDelay, + minDeposit: this.resolveMinDepositHint(paymentRequirements), + receiverAuthorizer, + ...(serverSigned + ? { operator: this.config.operator!.address, voucherSigner: "server" } + : {}), + }, + }); + } + + /** + * Whether a route's accept is served in server-signed mode. + * + * Server mode needs a configured operator and is the default for every + * batch route once one is configured; a route opts back out with + * `extra.voucherSigner: "client"`. + * + * @param paymentRequirements - Route accept, before or after enrichment + * @returns True when the operator signs vouchers for this accept + */ + isServerSigned(paymentRequirements: PaymentRequirements): boolean { + const routeMode = paymentRequirements.extra?.voucherSigner; + if (routeMode !== undefined && routeMode !== "client" && routeMode !== "server") { + throw new Error('extra.voucherSigner must be "client" or "server"'); + } + if (routeMode === "server" && !this.config.operator) { + throw new Error( + 'extra.voucherSigner: "server" requires an operator signer in BatchSvmServerConfig', + ); + } + return this.config.operator !== undefined && routeMode !== "client"; + } + + /** + * Build the redemption worker over this scheme's channel store: it claims + * accumulated vouchers and distributes what they settle, through the same + * facilitator the server verifies with. + * + * `requirements` are the terms channels were opened against — network, + * asset, `payTo` and `extra.feePayer` — normally the output of + * {@link enhancePaymentRequirements} for the route's requirements and the + * facilitator's `/supported` kind. + * + * @param facilitator - Facilitator client that submits redemption payloads + * @param requirements - Enhanced requirements the channels were opened against + * @param options - Worker tuning: batch size, watermark reader, error hook + * @returns A worker the caller starts, or drives with `redeem()` + */ + createChannelManager( + facilitator: Pick, + requirements: PaymentRequirements, + options: Omit< + BatchChannelManagerConfig, + "store" | "settle" | "requirements" | "receiverAuthorizer" + > = {}, + ): BatchChannelManager { + if (typeof requirements.extra?.feePayer !== "string") { + throw new Error( + "createChannelManager requires requirements.extra.feePayer; pass the requirements " + + "returned by enhancePaymentRequirements for the facilitator's /supported kind", + ); + } + return new BatchChannelManager({ + ...options, + ...(this.config.receiverAuthorizer + ? { receiverAuthorizer: this.config.receiverAuthorizer } + : {}), + requirements, + settle: (payload, accepted) => + facilitator.settle(payload as unknown as PaymentPayload, accepted), + store: this.store, + }); + } + + /** + * Resolve the deposit target advertised for one request. + * + * @param paymentRequirements - Route requirements, optionally with a target override + * @returns The normalized deposit target in atomic units + */ + resolveMinDepositHint(paymentRequirements: PaymentRequirements): string { + const amount = BigInt(paymentRequirements.amount); + const override = paymentRequirements.extra?.minDeposit; + let configured: bigint | undefined; + if (typeof override === "string") { + if (/^\d+$/.test(override)) { + configured = parsePositiveAmount(override, "minDeposit"); + } else { + const asset = findDefaultAsset(paymentRequirements.asset, paymentRequirements.network); + if (!asset) { + throw new Error( + `extra.minDeposit money values are only supported for default assets; ` + + `use an integer atomic string for ${paymentRequirements.asset} on ${paymentRequirements.network}.`, + ); + } + const parsed = parseMoney(override); + if (parsed.symbol !== undefined && parsed.symbol !== asset.symbol) { + throw new Error(`extra.minDeposit currency must match ${asset.symbol}`); + } + configured = parsePositiveAmount( + convertToTokenAmount(parsed.amount, asset.decimals), + "minDeposit", + ); + } + } + const minimum = + configured ?? + amount * + (this.isServerSigned(paymentRequirements) + ? DEFAULT_SERVER_SIGNED_MIN_DEPOSIT_MULTIPLIER + : DEFAULT_SERVER_MIN_DEPOSIT_MULTIPLIER); + return (minimum > amount ? minimum : amount).toString(); + } + + /** + * Reserve channel capacity and validate payloads before facilitator verify. + * + * @param ctx - Verify hook context + * @returns Abort directive, local verify skip, or void to continue to the facilitator + */ + private async beforeVerify( + ctx: VerifyContext, + ): Promise< + | void + | { abort: true; reason: string; message?: string } + | { skip: true; result: VerifyResponse } + > { + const raw = ctx.paymentPayload.payload; + if (!isBatchPayload(raw)) return; + try { + const channelId = await this.validatePayload(raw, ctx.requirements); + const state = await this.store.get(channelId); + if (state) { + this.assertStoredConfig(state, raw.channelConfig, ctx.requirements); + } + if (raw.type === "deposit" && !state) { + const extra = ctx.requirements.extra!; + const receiverAuthorizer = extra.receiverAuthorizer; + if (typeof receiverAuthorizer !== "string") { + throw new Error(BatchError.RECEIVER_AUTHORIZER_MISMATCH); + } + try { + await verifyOpenTransaction(raw.deposit.transaction, { + authorizedSigner: raw.channelConfig.payerAuthorizer, + expectedBindingMemo: encodeReceiverBindingMemo(receiverAuthorizer), + feePayer: String(extra.feePayer), + from: raw.channelConfig.payer, + maxCap: parseU64(raw.deposit.amount, "deposit.amount"), + memo: typeof extra.memo === "string" ? extra.memo : undefined, + mint: ctx.requirements.asset, + openSlot: BigInt(raw.channelConfig.openSlot), + payee: String(extra.feePayer), + recipients: [{ bps: FULL_SPLIT_BPS, recipient: ctx.requirements.payTo }], + tokenProgram: String(extra.tokenProgram), + withdrawDelay: raw.channelConfig.withdrawDelay, + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + const reason = detail.includes("receiver binding") + ? BatchError.RECEIVER_AUTHORIZER_MISMATCH + : BatchError.SETUP_TRANSACTION; + throw new Error(`${reason}: ${detail}`); + } + } + + if (raw.type === "deposit" || raw.type === "voucher" || raw.type === "authorization") { + const proof = proofOf(raw); + // A channel this server holds no record for is not a dead end: the + // facilitator verifies the voucher against confirmed onchain state, + // and `afterVerify` rebuilds the record from the snapshot it returns. + // Refusing here instead would strand the payer's escrow behind a + // forced close every time this server lost its store. + if ( + (raw.type === "voucher" || raw.type === "authorization") && + (!state || !isOnchainStateFresh(state, this.config.onchainStateTtlMs)) + ) { + this.requestContexts.set(ctx.paymentPayload, { + channelId, + proof, + requiresCumulativeCheck: true, + ...(proof.signer === "server" + ? { + ceiling: BigInt(ctx.requirements.amount), + requestId: proof.authorization.requestId, + } + : {}), + }); + return; + } + const expected = (state?.chargedCumulativeAmount ?? 0n) + BigInt(ctx.requirements.amount); + const topUp = raw.type === "deposit" && state ? { topUp: true as const } : {}; + switch (proof.signer) { + case "client": { + const submitted = BigInt(proof.voucher.maxClaimableAmount); + const replay = + state !== undefined && + submitted === state.chargedCumulativeAmount && + proof.voucher.signature === state.highestVoucherSignature; + if (replay) throw new Error(CHANNEL_BUSY); + if (!replay && submitted !== expected) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + this.requestContexts.set(ctx.paymentPayload, { + channelId, + proof, + cumulative: submitted, + ceiling: BigInt(ctx.requirements.amount), + ...topUp, + }); + break; + } + case "server": + this.requestContexts.set(ctx.paymentPayload, { + channelId, + proof, + ceiling: BigInt(ctx.requirements.amount), + requestId: proof.authorization.requestId, + ...topUp, + }); + break; + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + } else if (raw.type === "refund") { + if (!state) throw new Error(BatchError.CHANNEL_STATE); + const serverMode = (raw.channelConfig.voucherSigner ?? "client") === "server"; + if (serverMode) { + if (raw.voucher !== undefined) throw new Error(BatchError.VOUCHER_SIGNATURE); + const authorization = raw.authorization; + if (!authorization) throw new Error(BatchError.VOUCHER_SIGNATURE); + this.requestContexts.set(ctx.paymentPayload, { + channelId, + ceiling: 0n, + requestId: authorization.requestId, + }); + } else { + if (!raw.voucher) throw new Error(BatchError.VOUCHER_SIGNATURE); + if (BigInt(raw.voucher.maxClaimableAmount) !== state.chargedCumulativeAmount) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + this.requestContexts.set(ctx.paymentPayload, { channelId }); + } + } + // Deposits and refunds carry transactions whose complete instruction and + // onchain-state checks belong to the facilitator. Only a steady-state + // proof backed by a fresh local snapshot can use the local fast path. + if (raw.type !== "voucher" && raw.type !== "authorization") return; + return { + skip: true, + result: { isValid: true, payer: raw.channelConfig.payer, extra: { channelId } }, + }; + } catch (error) { + return { + abort: true, + reason: classifyError(error), + message: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Merge facilitator snapshots, create reservations, and optionally skip the handler. + * + * @param ctx - Post-verify hook context + * @returns Abort directive, skip-handler directive, or void to run the handler + */ + private async afterVerify( + ctx: VerifyResultContext, + ): Promise< + | void + | { abort: true; reason: string; message?: string } + | { skipHandler: true; response?: SkipHandlerDirective } + > { + const raw = ctx.paymentPayload.payload; + if (!isBatchPayload(raw)) return; + if (!ctx.result.isValid) { + if (ctx.result.invalidReason === BatchError.CHANNEL_CLOSING) { + await this.markChannelClosing(ctx.paymentPayload); + } + return; + } + const request = this.requestContexts.get(ctx.paymentPayload); + if (!request) return this.abort(BatchError.CHANNEL_STATE, "missing request state"); + // The facilitator has now confirmed this payload against onchain state and + // returned the channel snapshot. Persisting it is what lets a server with + // no record of a live channel serve it, and what keeps `deposit`, `settled` + // and the close state from drifting behind the chain. + const snapshot = readVerifiedChannelState(ctx.result); + if (request.requiresCumulativeCheck && !snapshot) { + return this.abort(BatchError.CHANNEL_STATE, "facilitator did not return channel state"); + } + if (snapshot && !this.applySnapshot(request.channelId, snapshot)) { + return this.abort(BatchError.CHANNEL_STATE, "verified channel snapshot is unusable"); + } + if (snapshot) { + await this.persistSnapshot(request.channelId, raw, ctx.requirements, snapshot); + } + + // A client-signed voucher must be checked against the refreshed baseline. + if (request.requiresCumulativeCheck && request.proof?.signer === "client") { + const state = await this.store.get(request.channelId); + if (!state) return this.abort(BatchError.CHANNEL_STATE, "channel state unavailable"); + const submitted = BigInt(request.proof.voucher.maxClaimableAmount); + const expected = state.chargedCumulativeAmount + BigInt(ctx.requirements.amount); + if (submitted !== expected) { + // The corrective 402 that follows carries this rebuilt snapshot and no + // voucher proof, so the client resynchronizes from onchain state. + return this.abort( + BatchError.CUMULATIVE_AMOUNT_MISMATCH, + `voucher authorizes ${submitted}, expected ${expected}`, + ); + } + } + + const pendingId = `${Date.now()}:${(this.reservationSequence += 1)}`; + const expiresAt = Date.now() + Math.max(5_000, ctx.requirements.maxTimeoutSeconds * 1_000); + let operationReserved = false; + try { + if (request.requestId && request.ceiling !== undefined) { + const reserved = await this.operationStore.reserve( + request.channelId, + request.requestId, + request.ceiling, + ); + if (!reserved.created) { + throw new Error(CHANNEL_BUSY); + } + operationReserved = true; + } + await this.store.update(request.channelId, current => { + const state = current ?? this.provisionalState(raw, ctx.requirements, request.channelId); + if (state.status !== "open") throw new Error(BatchError.CLOSE_STATE); + this.assertStoredConfig(state, raw.channelConfig, ctx.requirements); + const reservations = liveReservations(state.reservations); + const active = Object.values(reservations); + const kind = raw.type === "refund" ? "close" : request.requestId ? "server" : "client"; + if (kind !== "server" && active.length > 0) throw new Error(CHANNEL_BUSY); + if (kind === "server" && active.some(reservation => reservation.kind !== "server")) { + throw new Error(CHANNEL_BUSY); + } + const maxClaimableAmount = + raw.type === "refund" + ? state.signedMaxClaimable + : (request.cumulative ?? + state.chargedCumulativeAmount + BigInt(ctx.requirements.amount)); + const reservedCeilings = active.reduce((sum, reservation) => sum + reservation.ceiling, 0n); + const ceiling = raw.type === "refund" ? 0n : BigInt(ctx.requirements.amount); + const deposit = + raw.type === "deposit" && request.topUp + ? state.deposit + BigInt(raw.deposit.amount) + : state.deposit; + if ( + maxClaimableAmount > deposit || + state.chargedCumulativeAmount + reservedCeilings + ceiling > deposit + ) { + throw new Error(BatchError.CUMULATIVE_EXCEEDS_DEPOSIT); + } + return { + ...state, + reservations: { + ...reservations, + [pendingId]: { + ceiling, + expiresAt, + ...(request.requestId ? { requestId: request.requestId } : {}), + kind, + }, + }, + }; + }); + this.requestContexts.set(ctx.paymentPayload, { ...request, pendingId }); + if (raw.type === "refund") { + return { + skipHandler: true, + response: { body: { channelId: request.channelId, message: "Refund initiated" } }, + }; + } + } catch (error) { + if (operationReserved && request.requestId) { + await this.operationStore.release(request.channelId, request.requestId); + } + this.requestContexts.delete(ctx.paymentPayload); + return this.abort( + classifyError(error), + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Commit voucher charges for steady-state requests before facilitator settle. + * + * @param ctx - Pre-settle hook context + * @returns Abort directive, local settle skip, or void to continue to the facilitator + */ + private async beforeSettle( + ctx: SettleContext, + ): Promise< + | void + | { abort: true; reason: string; message?: string } + | { skip: true; result: SettleResponse } + > { + const raw = ctx.paymentPayload.payload; + if (!isBatchPayload(raw) || raw.type === "refund") return; + + const request = this.requestContexts.get(ctx.paymentPayload); + const pendingId = request?.pendingId; + if (!request || !pendingId) return this.abort(CHANNEL_BUSY, "missing reservation"); + const state = await this.store.get(request.channelId); + if (!state?.reservations?.[pendingId]) { + return this.abort(CHANNEL_BUSY, "reservation changed"); + } + + // Deposits go to the facilitator, which broadcasts the open/top_up + // transaction in this post-handler settle; the voucher commits in + // afterSettle once the deposit succeeds. + if (raw.type === "deposit") return; + + try { + const actual = BigInt(ctx.requirements.amount); + const ceiling = request.ceiling ?? actual; + const proof = request.proof ?? proofOf(raw); + if (actual > ceiling || (proof.signer === "client" && actual !== ceiling)) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + const committed = await this.commitCharge({ ...request, pendingId }, proof, actual); + this.requestContexts.delete(ctx.paymentPayload); + return { skip: true, result: acceptedResponse(committed, ctx.requirements) }; + } catch (error) { + return this.abort( + classifyError(error), + error instanceof Error ? error.message : String(error), + ); + } + } + + /** + * Finalize deposits and refunds after a successful facilitator settlement. + * + * @param ctx - Post-settle hook context + */ + private async afterSettle(ctx: SettleResultContext): Promise { + const raw = ctx.paymentPayload.payload; + if (!ctx.result.success || !isBatchPayload(raw)) return; + const request = this.requestContexts.get(ctx.paymentPayload); + const pendingId = request?.pendingId; + if (!request || !pendingId) return; + + if (raw.type === "deposit") { + const reserved = await this.store.get(request.channelId); + if (!reserved?.reservations?.[pendingId]) { + throw new Error(CHANNEL_BUSY); + } + const actual = BigInt(ctx.requirements.amount); + const ceiling = request.ceiling ?? actual; + const proof = request.proof ?? proofOf(raw); + if (actual > ceiling || (proof.signer === "client" && actual !== ceiling)) { + throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + } + const committed = await this.commitCharge( + { ...request, pendingId }, + proof, + actual, + current => { + const confirmed = readChannelState(ctx.result); + return { + // A top-up raises the escrow ceiling; without this the stored deposit + // would stay at the original open amount forever. + deposit: confirmedDeposit(current.deposit, confirmed.balance), + openSignature: ctx.result.transaction, + settled: BigInt(confirmed.totalClaimed), + onchainSyncedAt: Date.now(), + }; + }, + ); + this.settlementExtras.set( + ctx.paymentPayload, + settlementExtra(committed, ctx.requirements.amount), + ); + this.requestContexts.delete(ctx.paymentPayload); + return; + } + + if (raw.type === "refund") { + await this.store.update(request.channelId, current => { + if (!current?.reservations?.[pendingId]) { + throw new Error(CHANNEL_BUSY); + } + const snapshot = readChannelState(ctx.result); + const reservations = withoutReservation(current.reservations, pendingId); + // A cooperative close seals and distributes in one step; only a + // payer-signed request_close leaves a grace period running. + if (snapshot.withdrawRequestedAt === 0) { + const confirmed = BigInt(snapshot.totalClaimed); + const settled = confirmed > current.settled ? confirmed : current.settled; + return { + ...current, + closeSignature: ctx.result.transaction, + onchainSyncedAt: Date.now(), + payoutWatermark: settled, + reservations, + settled, + status: "distributed", + }; + } + return { + ...current, + closeRequestedAt: snapshot.withdrawRequestedAt, + closeSignature: ctx.result.transaction, + onchainSyncedAt: Date.now(), + reservations, + status: "closing", + }; + }); + this.requestContexts.delete(ctx.paymentPayload); + } + } + + /** + * React to verify failures that indicate the payer is closing the channel. + * + * @param ctx - Verify failure hook context + */ + private async onVerifyFailure(ctx: VerifyFailureContext): Promise { + if (ctx.error.message.includes(BatchError.CHANNEL_CLOSING)) { + await this.markChannelClosing(ctx.paymentPayload); + } + } + + /** + * Stop serving a paid request's channel once the facilitator reports the + * payer is closing it, and let the host start a redemption pass. + * + * @param payload - The payment whose verification failed + */ + private async markChannelClosing(payload: DeepReadonly): Promise { + const raw = payload.payload; + if (!isBatchPayload(raw) || raw.type === "refund") return; + const channelId = this.requestContexts.get(payload)?.channelId; + if (channelId === undefined) return; + const state = await this.store.get(channelId); + if (state?.status !== "open") return; + await this.store.update(channelId, current => { + const base = current ?? state; + return base.status === "open" ? { ...base, status: "closing" } : base; + }); + this.config.onChannelClosing?.(channelId); + } + + /** + * Drop reservations when settlement fails after verify. + * + * @param ctx - Settle failure hook context + */ + private async onSettleFailure(ctx: SettleFailureContext): Promise { + await this.clearReservation(ctx.paymentPayload); + } + + /** + * Drop reservations when a verified payment is canceled before settle. + * + * @param ctx - Cancellation hook context + */ + private async onCanceled(ctx: VerifiedPaymentCanceledContext): Promise { + await this.clearReservation(ctx.paymentPayload); + } + + /** + * Remove the pending reservation and release any server-signed operation lock. + * + * @param payload - Payment whose reservation should be cleared + */ + private async clearReservation(payload: DeepReadonly): Promise { + const request = this.requestContexts.get(payload); + this.requestContexts.delete(payload); + if (!request?.pendingId) return; + await this.store.update(request.channelId, current => { + if (!current) throw new Error(BatchError.CHANNEL_STATE); + if (!current.reservations?.[request.pendingId!]) return current; + return { + ...current, + reservations: withoutReservation(current.reservations, request.pendingId!), + }; + }); + if (request.requestId) { + await this.operationStore.release(request.channelId, request.requestId); + } + } + + /** + * Validate payload bindings against requirements and derive the channel PDA. + * + * @param raw - Batch-settlement payload from the client + * @param requirements - Accepted payment requirements + * @returns Channel id for the payload + */ + private async validatePayload( + raw: BatchPayload, + requirements: PaymentRequirements, + ): Promise { + const extra = requirements.extra; + if (!extra || (extra.paymentFlow !== undefined && extra.paymentFlow !== "authorization")) { + throw new Error(BatchError.PAYMENT_FLOW); + } + if (typeof extra.feePayer !== "string") throw new Error(BatchError.FEE_PAYER_MISMATCH); + const voucherSigner = extra.voucherSigner ?? "client"; + if (voucherSigner !== "client" && voucherSigner !== "server") { + throw new Error(BatchError.CHANNEL_STATE); + } + if ((raw.channelConfig.voucherSigner ?? "client") !== voucherSigner) { + throw new Error(BatchError.CHANNEL_STATE); + } + const operator = extra.operator; + if ( + (voucherSigner === "server" && + (typeof operator !== "string" || + raw.channelConfig.payerAuthorizer !== operator || + this.config.operator?.address !== operator)) || + (voucherSigner === "client" && operator !== undefined) + ) { + throw new Error(BatchError.CHANNEL_STATE); + } + if ( + raw.channelConfig.payer === extra.feePayer || + raw.channelConfig.payerAuthorizer === extra.feePayer + ) { + throw new Error(BatchError.FEE_PAYER_MISMATCH); + } + if ( + raw.channelConfig.receiver !== requirements.payTo || + raw.channelConfig.token !== requirements.asset + ) { + throw new Error(BatchError.CHANNEL_STATE); + } + if (raw.channelConfig.withdrawDelay !== extra.withdrawDelay) { + throw new Error(BatchError.WITHDRAW_DELAY_MISMATCH); + } + if ( + typeof extra.receiverAuthorizer !== "string" || + raw.channelConfig.receiverAuthorizer !== extra.receiverAuthorizer || + (this.config.receiverAuthorizer !== undefined && + extra.receiverAuthorizer !== this.config.receiverAuthorizer.address) + ) { + throw new Error(BatchError.RECEIVER_AUTHORIZER_MISMATCH); + } + if ( + extra.tokenProgram !== TOKEN_PROGRAM_ADDRESS && + extra.tokenProgram !== TOKEN_2022_PROGRAM_ADDRESS + ) { + throw new Error(BatchError.TOKEN_PROGRAM); + } + const channelId = await findPaymentChannelPda({ + authorizedSigner: raw.channelConfig.payerAuthorizer, + mint: raw.channelConfig.token, + openSlot: BigInt(raw.channelConfig.openSlot), + payee: extra.feePayer, + payer: raw.channelConfig.payer, + salt: BigInt(raw.channelConfig.salt), + }); + const proofAmount = + raw.type === "refund" && voucherSigner === "server" ? "0" : requirements.amount; + await this.validateRequestProof(raw, channelId, voucherSigner, proofAmount); + if ( + raw.type === "deposit" && + this.config.enforceMinDeposit === true && + parseU64(raw.deposit.amount, "deposit.amount") < + parseU64(this.resolveMinDepositHint(requirements), "minDeposit") + ) { + throw new Error(BatchError.DEPOSIT_BELOW_MIN_DEPOSIT); + } + return channelId; + } + + /** + * Validate the voucher or payer authorization carried by a request payload. + * + * @param raw - Batch-settlement payload + * @param channelId - Derived channel id + * @param voucherSigner - Expected signer mode from requirements + * @param authorizedAmount - Amount the proof must authorize + */ + private async validateRequestProof( + raw: BatchPayload, + channelId: string, + voucherSigner: "client" | "server", + authorizedAmount: string, + ): Promise { + if (raw.type === "refund") { + if (voucherSigner === "server") { + if (raw.voucher !== undefined || !raw.authorization) { + throw new Error(BatchError.VOUCHER_SIGNATURE); + } + await this.assertPayerAuthorization(raw.authorization, raw, channelId, authorizedAmount); + return; + } + if (raw.authorization !== undefined || !raw.voucher) { + throw new Error(BatchError.VOUCHER_SIGNATURE); + } + await this.assertSignedVoucher(raw.voucher, channelId, raw.channelConfig.payerAuthorizer); + return; + } + const proof = proofOf(raw); + if (proof.signer !== voucherSigner) throw new Error(BatchError.VOUCHER_SIGNATURE); + switch (proof.signer) { + case "client": + await this.assertSignedVoucher(proof.voucher, channelId, raw.channelConfig.payerAuthorizer); + return; + case "server": + await this.assertPayerAuthorization(proof.authorization, raw, channelId, authorizedAmount); + return; + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + } + + /** + * Verify a client-signed cumulative voucher for the channel. + * + * @param voucher - Voucher presented on the payload + * @param channelId - Expected channel id + * @param signer - Payer authorizer that must have signed the voucher + */ + private async assertSignedVoucher( + voucher: BatchVoucher, + channelId: string, + signer: string, + ): Promise { + if (voucher.channelId !== channelId) throw new Error(BatchError.CHANNEL_ID_MISMATCH); + this.assertExpiry(voucher); + const valid = await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: BigInt(voucher.maxClaimableAmount), + expiresAt: BigInt(voucher.expiresAt), + }), + signatureBase58: voucher.signature, + signerBase58: signer, + }); + if (!valid) throw new Error(BatchError.VOUCHER_SIGNATURE); + } + + /** + * Verify a server-signed payer authorization for the request. + * + * @param authorization - Authorization presented on the payload + * @param raw - Parent batch payload + * @param channelId - Expected channel id + * @param authorizedAmount - Amount the authorization must cover + */ + private async assertPayerAuthorization( + authorization: BatchAuthorization, + raw: BatchPayload, + channelId: string, + authorizedAmount: string, + ): Promise { + if ( + authorization.channelId !== channelId || + authorization.payer !== raw.channelConfig.payer || + authorization.authorizedAmount !== authorizedAmount || + typeof authorization.requestId !== "string" || + authorization.requestId.length === 0 || + !(await verifyBatchAuthorization(authorization, raw.channelConfig.payerAuthorizer)) + ) { + throw new Error(BatchError.VOUCHER_SIGNATURE); + } + } + + /** + * Commit a reserved charge, persist the new voucher watermark, and complete server ops. + * + * @param request - Request context including the reservation id + * @param proof - Client voucher or server authorization proof + * @param actual - Measured charge for this request + * @param patch - Optional extra fields to merge after a deposit settle + * @returns Updated channel state after the commit + */ + private async commitCharge( + request: RequestContext & { pendingId: string }, + proof: BatchProof, + actual: bigint, + patch?: (current: ChannelState) => Partial, + ): Promise { + return this.store.update(request.channelId, async current => { + const reservation = current?.reservations?.[request.pendingId]; + if (!current || !reservation) throw new Error(CHANNEL_BUSY); + if (actual > reservation.ceiling) throw new Error(BatchError.CUMULATIVE_AMOUNT_MISMATCH); + const cumulative = current.chargedCumulativeAmount + actual; + const voucher = await this.voucherForCharge(proof, request.channelId, cumulative); + if (request.requestId) { + await this.operationStore.complete({ + actual, + ceiling: reservation.ceiling, + channelId: request.channelId, + cumulative, + requestId: request.requestId, + status: "completed", + }); + } + return { + ...current, + ...patch?.(current), + chargedCumulativeAmount: BigInt(voucher.maxClaimableAmount), + highestVoucherExpiresAt: voucher.expiresAt, + highestVoucherSignature: voucher.signature, + reservations: withoutReservation(current.reservations, request.pendingId), + signedMaxClaimable: BigInt(voucher.maxClaimableAmount), + }; + }); + } + + /** + * Resolve the voucher that records the new cumulative charge. + * + * @param proof - Proof from the verified payload + * @param channelId - Channel being charged + * @param cumulative - New cumulative claimable amount + * @returns Voucher to persist as the watermark + */ + private async voucherForCharge( + proof: BatchProof, + channelId: string, + cumulative: bigint, + ): Promise { + switch (proof.signer) { + case "client": + return proof.voucher; + case "server": + return this.signOperatorVoucher(channelId, cumulative); + default: { + const unexpected: never = proof; + throw new Error(String(unexpected)); + } + } + } + + /** + * Batch client vouchers must use the fixed zero expiry sentinel. + * + * @param voucher - Voucher whose expiry is checked + */ + private assertExpiry(voucher: BatchVoucher): void { + if (voucher.expiresAt !== 0) throw new Error(BatchError.VOUCHER_EXPIRY); + } + + /** + * Sign an operator voucher for server-signed channels. + * + * @param channelId - Channel the voucher is for + * @param cumulativeAmount - Cumulative claimable amount + * @returns Operator-signed voucher + */ + private async signOperatorVoucher( + channelId: string, + cumulativeAmount: bigint, + ): Promise { + if (!this.config.operator) throw new Error(BatchError.VOUCHER_SIGNATURE); + return { + channelId, + expiresAt: CLIENT_VOUCHER_EXPIRES_AT, + maxClaimableAmount: cumulativeAmount.toString(), + signature: await signVoucher(this.config.operator, { + channelId, + cumulativeAmount, + expiresAt: BigInt(CLIENT_VOUCHER_EXPIRES_AT), + }), + }; + } + + /** + * Whether a verified snapshot can be applied to this channel at all. + * + * A snapshot that reports a settled watermark above the escrow, or a channel + * already closing, is not something to build a serving record on. + * + * @param channelId - Channel the snapshot must describe + * @param snapshot - Confirmed onchain snapshot from the facilitator + * @returns Whether the snapshot may be persisted + */ + private applySnapshot(channelId: string, snapshot: VerifiedChannelState): boolean { + if (snapshot.channelId !== undefined && snapshot.channelId !== channelId) return false; + if (snapshot.withdrawRequestedAt !== 0) return false; + if (snapshot.balance !== undefined && snapshot.totalClaimed > snapshot.balance) return false; + return true; + } + + /** + * Merge a confirmed onchain snapshot into the channel record, creating it + * when this server has none. + * + * A rebuilt record starts charging from the onchain settled watermark. That + * is the most this server can honestly claim to have charged: a voucher + * above it is unclaimable without the signature that vanished with the + * store, so starting there forfeits nothing that was not already lost — and + * starting from zero would accept vouchers the program can never settle, + * because `settle` requires a strictly increasing watermark. + * + * @param channelId - Channel to merge into + * @param raw - The payload the facilitator validated against the chain + * @param requirements - Requirements that payload answered + * @param snapshot - Confirmed onchain snapshot + */ + private async persistSnapshot( + channelId: string, + raw: BatchPayload, + requirements: PaymentRequirements, + snapshot: VerifiedChannelState, + ): Promise { + await this.store.update(channelId, current => { + const base = current ?? this.recoveredState(raw, requirements, channelId, snapshot); + return { + ...base, + // An escrow ceiling only ever rises; never let a stale read lower a + // deposit the chain has confirmed. + deposit: + snapshot.balance !== undefined && snapshot.balance > base.deposit + ? snapshot.balance + : base.deposit, + settled: snapshot.totalClaimed > base.settled ? snapshot.totalClaimed : base.settled, + chargedCumulativeAmount: + snapshot.totalClaimed > base.chargedCumulativeAmount + ? snapshot.totalClaimed + : base.chargedCumulativeAmount, + signedMaxClaimable: + snapshot.totalClaimed > base.signedMaxClaimable + ? snapshot.totalClaimed + : base.signedMaxClaimable, + onchainSyncedAt: Date.now(), + ...(snapshot.withdrawRequestedAt !== 0 + ? { closeRequestedAt: snapshot.withdrawRequestedAt, status: "closing" as const } + : {}), + }; + }); + } + + /** + * A channel record rebuilt from a confirmed onchain snapshot. + * + * Every immutable field comes from the payload the facilitator validated + * against that snapshot, so a record can only be built for a channel whose + * bindings the client itself authorized. + * + * @param raw - The payload the facilitator validated against the chain + * @param requirements - Requirements that payload answered + * @param channelId - Channel the record is for + * @param snapshot - Confirmed onchain snapshot + * @returns A channel record seeded from the chain + */ + private recoveredState( + raw: BatchPayload, + requirements: PaymentRequirements, + channelId: string, + snapshot: VerifiedChannelState, + ): ChannelState { + const extra = requirements.extra!; + return { + channelConfig: raw.channelConfig, + channelId, + chargedCumulativeAmount: snapshot.totalClaimed, + deposit: snapshot.balance ?? 0n, + feePayer: String(extra.feePayer), + mint: requirements.asset, + openSlot: BigInt(raw.channelConfig.openSlot), + payer: raw.channelConfig.payer, + payerAuthorizer: raw.channelConfig.payerAuthorizer, + payoutWatermark: 0n, + receiver: requirements.payTo, + receiverAuthorizer: raw.channelConfig.receiverAuthorizer, + salt: BigInt(raw.channelConfig.salt), + settled: snapshot.totalClaimed, + signedMaxClaimable: snapshot.totalClaimed, + status: "open", + tokenProgram: String(extra.tokenProgram), + withdrawDelay: raw.channelConfig.withdrawDelay, + }; + } + + /** + * Seed channel state for a first deposit before the facilitator confirms open. + * + * @param raw - Deposit payload + * @param requirements - Accepted requirements + * @param channelId - Derived channel id + * @returns Provisional open channel record + */ + private provisionalState( + raw: BatchPayload, + requirements: PaymentRequirements, + channelId: string, + ): ChannelState { + if (raw.type !== "deposit") throw new Error(BatchError.CHANNEL_STATE); + const extra = requirements.extra!; + return { + channelConfig: raw.channelConfig, + channelId, + chargedCumulativeAmount: 0n, + deposit: parseU64(raw.deposit.amount, "deposit.amount"), + feePayer: String(extra.feePayer), + mint: requirements.asset, + openSlot: BigInt(raw.channelConfig.openSlot), + payer: raw.channelConfig.payer, + payerAuthorizer: raw.channelConfig.payerAuthorizer, + payoutWatermark: 0n, + receiver: requirements.payTo, + receiverAuthorizer: raw.channelConfig.receiverAuthorizer, + salt: BigInt(raw.channelConfig.salt), + settled: 0n, + signedMaxClaimable: 0n, + status: "open", + tokenProgram: String(extra.tokenProgram), + withdrawDelay: raw.channelConfig.withdrawDelay, + }; + } + + /** + * Ensure stored channel bindings still match the challenged payload and authorizer. + * + * @param state - Stored channel record + * @param config - Channel config from the payload + * @param requirements - Requirements the payload answers + */ + private assertStoredConfig( + state: ChannelState, + config: BatchChannelConfig, + requirements: PaymentRequirements, + ): void { + if (JSON.stringify(state.channelConfig) !== JSON.stringify(config)) { + throw new Error(BatchError.CHANNEL_STATE); + } + const challenged = requirements.extra?.receiverAuthorizer; + if ( + state.channelConfig.receiverAuthorizer !== challenged || + (this.config.receiverAuthorizer !== undefined && + challenged !== this.config.receiverAuthorizer.address) + ) { + throw new Error(BatchError.RECEIVER_AUTHORIZER_MISMATCH); + } + } + + /** + * Build a hook abort result with a machine reason and human message. + * + * @param reason - Machine-readable failure reason + * @param message - Human-readable detail + * @returns Abort directive for scheme hooks + */ + private abort(reason: string, message: string) { + return { abort: true as const, message, reason }; + } + + /** + * Convert a parsed money amount to the default stablecoin for the network. + * + * @param amount - Decimal amount string + * @param network - Network identifier + * @param symbol - Optional stablecoin symbol + * @returns Asset amount in atomic units + */ + private defaultMoneyConversion(amount: string, network: Network, symbol?: string): AssetAmount { + const assetInfo = getDefaultAsset(network, symbol); + return { + amount: convertToTokenAmount(amount, assetInfo.decimals), + asset: assetInfo.asset, + extra: {}, + }; + } +} + +/** + * Build a successful local settle response after committing a charge. + * + * @param state - Channel state after the commit + * @param requirements - Requirements that were settled + * @returns Settle response with channel extras for the client + */ +function acceptedResponse(state: ChannelState, requirements: PaymentRequirements): SettleResponse { + return { + success: true, + payer: state.payer, + transaction: "", + network: requirements.network, + amount: "", + extra: settlementExtra(state, requirements.amount), + }; +} + +/** + * Settlement extras returned to the client after a committed charge. + * + * @param state - Channel state after the commit + * @param chargedAmount - Amount charged on this request + * @returns Extra fields for the settle response + */ +function settlementExtra(state: ChannelState, chargedAmount: string): Record { + const serverSigned = state.channelConfig.voucherSigner === "server"; + return { + channelState: snapshot(state), + commitmentId: `${state.channelId}:${state.signedMaxClaimable}`, + chargedAmount, + ...(serverSigned ? { voucher: serverVoucher(state) } : {}), + }; +} + +/** + * Operator voucher to echo back on server-signed channels when available. + * + * @param state - Channel state after the commit + * @returns Stored operator voucher, if any + */ +function serverVoucher(state: ChannelState): BatchVoucher | undefined { + if (state.highestVoucherSignature === undefined) return undefined; + return { + channelId: state.channelId, + expiresAt: state.highestVoucherExpiresAt ?? 0, + maxClaimableAmount: state.signedMaxClaimable.toString(), + signature: state.highestVoucherSignature, + }; +} + +/** + * Serialize channel state for settlement and 402 enrichment responses. + * + * @param state - Channel record to snapshot + * @returns Wire-shaped channel state object + */ +function snapshot(state: ChannelState) { + return { + channelId: state.channelId, + balance: state.deposit.toString(), + totalClaimed: state.settled.toString(), + withdrawRequestedAt: state.closeRequestedAt ?? 0, + chargedCumulativeAmount: state.chargedCumulativeAmount.toString(), + }; +} + +/** + * Drop expired reservations before enforcing capacity limits. + * + * @param reservations - Reservation map from channel state + * @returns Reservations that have not yet expired + */ +function liveReservations( + reservations: ChannelState["reservations"], +): NonNullable { + return Object.fromEntries( + Object.entries(reservations ?? {}).filter( + ([, reservation]) => reservation.expiresAt > Date.now(), + ), + ); +} + +/** + * Remove one reservation id from the map. + * + * @param reservations - Reservation map from channel state + * @param reservationId - Id to remove + * @returns Updated reservation map + */ +function withoutReservation( + reservations: ChannelState["reservations"], + reservationId: string, +): ChannelState["reservations"] { + return Object.fromEntries( + Object.entries(reservations ?? {}).filter(([id]) => id !== reservationId), + ); +} + +/** + * Merge settlement extras without overwriting keys already on the response. + * + * @param extra - New extra fields to attach + * @param existing - Extra fields already on the settle response + * @returns Filtered extras safe to merge + */ +function withoutExistingFields( + extra: Record, + existing: Record | undefined, +): Record { + return Object.fromEntries(Object.entries(extra).filter(([key]) => !(key in (existing ?? {})))); +} + +/** + * Read the channel snapshot from a facilitator verify response. + * + * Returns nothing when the response carries no `totalClaimed` — a `deposit` + * verify has no channel to snapshot yet — or when a field is not the shape it + * claims: a malformed snapshot must not become a serving record. + * + * @param result - The facilitator's verify response + * @returns The snapshot, or nothing when the response carries none + */ +function readVerifiedChannelState(result: VerifyResponse): VerifiedChannelState | undefined { + // Spec 4.5: the verify `extra` carries `channelId`, `balance`, `totalClaimed` + // and `withdrawRequestedAt` as flat siblings. A deposit verify has no + // channel yet and reports only `channelId`, which is not a snapshot. + const raw = result.extra; + if (typeof raw !== "object" || raw === null) return undefined; + const state = raw as Record; + const digits = (value: unknown): bigint | undefined => + typeof value === "string" && /^\d+$/.test(value) ? BigInt(value) : undefined; + const totalClaimed = digits(state.totalClaimed); + if (totalClaimed === undefined) return undefined; + const withdrawRequestedAt = + typeof state.withdrawRequestedAt === "number" && Number.isInteger(state.withdrawRequestedAt) + ? state.withdrawRequestedAt + : 0; + return { + ...(typeof state.channelId === "string" ? { channelId: state.channelId } : {}), + ...(digits(state.balance) !== undefined ? { balance: digits(state.balance) } : {}), + totalClaimed, + withdrawRequestedAt, + }; +} + +/** + * Read `channelState` from a facilitator settle response. + * + * @param result - Facilitator settle response + * @returns Parsed channel snapshot fields with safe defaults + */ +function readChannelState(result: SettleResponse): { + balance?: string; + totalClaimed: string; + withdrawRequestedAt: number; +} { + const raw = result.extra?.channelState; + if (typeof raw !== "object" || raw === null) { + return { totalClaimed: "0", withdrawRequestedAt: 0 }; + } + const state = raw as Record; + return { + ...(typeof state.balance === "string" ? { balance: state.balance } : {}), + totalClaimed: typeof state.totalClaimed === "string" ? state.totalClaimed : "0", + withdrawRequestedAt: + typeof state.withdrawRequestedAt === "number" ? state.withdrawRequestedAt : 0, + }; +} + +/** + * The channel deposit after a confirmed setup transaction. + * + * `deposit` is written once when the channel is provisioned, so without this a + * top-up's escrow would never reach stored state: the balance reported to the + * client would stay pinned at the original `open` amount, and the client — + * which adopts that balance as its own ceiling — would top up again on the next + * request that exceeded it, and on every request after that. The escrow would + * grow on chain while the client believed it never had. + * + * The facilitator reports the freshly-fetched on-chain deposit, so it is the + * authority. It is taken as a maximum rather than assigned, so a stale or + * malformed read can never lower a ceiling the chain has already confirmed. + * + * @param current - Deposit currently in stored state + * @param confirmed - `channelState.balance` from the settlement response + * @returns The deposit to store + */ +function confirmedDeposit(current: bigint, confirmed: string | undefined): bigint { + if (confirmed === undefined) return current; + try { + const balance = parseU64(confirmed, "channelState.balance"); + return balance > current ? balance : current; + } catch { + return current; + } +} + +/** + * Whether locally cached onchain fields are still within the configured TTL. + * + * @param state - Channel record with optional `onchainSyncedAt` + * @param configuredTtlMs - Override TTL in milliseconds + * @returns True when local snapshot may be trusted for verify fast-path + */ +function isOnchainStateFresh(state: ChannelState, configuredTtlMs: number | undefined): boolean { + if (state.onchainSyncedAt === undefined) return false; + const ttlMs = configuredTtlMs ?? defaultOnchainStateTtlMs(state.withdrawDelay); + return Date.now() - state.onchainSyncedAt <= ttlMs; +} + +/** + * Default onchain snapshot TTL derived from the channel withdraw delay. + * + * @param withdrawDelaySeconds - Channel withdraw delay in seconds + * @returns TTL in milliseconds, clamped between 30s and 5m + */ +function defaultOnchainStateTtlMs(withdrawDelaySeconds: number): number { + const withdrawDelayMs = Math.max(0, withdrawDelaySeconds) * 1_000; + return Math.min(5 * 60_000, Math.max(30_000, Math.floor(withdrawDelayMs / 3))); +} + +/** + * Parse a non-zero unsigned integer field from requirements or extras. + * + * @param value - Decimal string amount + * @param field - Field name used in error messages + * @returns Parsed positive amount + */ +function parsePositiveAmount(value: string, field: string): bigint { + const amount = parseU64(value, field); + if (amount === 0n) throw new Error(`${field} must resolve to a positive integer`); + return amount; +} + +/** + * Map thrown errors to batch-settlement machine reasons for hook responses. + * + * @param error - Caught error from hook logic + * @returns Machine-readable reason string + */ +function classifyError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes(CHANNEL_BUSY)) return CHANNEL_BUSY; + return Object.values(BatchError).find(value => message.includes(value)) ?? "transaction_failed"; +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/storage.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/storage.ts new file mode 100644 index 0000000000..990212ea6d --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/storage.ts @@ -0,0 +1,63 @@ +/** + * Per-channel server state and the store that holds it. + * + * `batch-settlement` is stateful: the operator tracks each channel's deposit, + * the highest accepted off-chain voucher (the watermark), and the on-chain + * settled / distributed amounts. The default {@link MemoryChannelStore} is an + * in-memory implementation with per-channel serialization; integrators swap in + * a durable store for production. See the spec's §7 "Server state". + */ + +import type { ChannelState, ChannelStore } from "./types"; + +/** In-memory {@link ChannelStore} with per-channel serialization. */ +export class MemoryChannelStore implements ChannelStore { + private readonly channels = new Map(); + private readonly locks = new Map>(); + + /** @inheritdoc */ + /** + * @inheritdoc + * @returns Every channel this store holds + */ + list(): Promise { + return Promise.resolve([...this.channels.values()]); + } + + /** + * @inheritdoc + * @param channelId - Channel PDA (base58) + * @returns The state, or undefined if unknown + */ + get(channelId: string): Promise { + return Promise.resolve(this.channels.get(channelId)); + } + + /** @inheritdoc */ + put(state: ChannelState): Promise { + this.channels.set(state.channelId, state); + return Promise.resolve(); + } + + /** @inheritdoc */ + async update( + channelId: string, + updater: (current: ChannelState | undefined) => ChannelState | Promise, + ): Promise { + const prior = this.locks.get(channelId) ?? Promise.resolve(); + const run = prior.then(async () => { + const next = await updater(this.channels.get(channelId)); + this.channels.set(channelId, next); + return next; + }); + // Keep the lock chain alive regardless of this run's outcome. + this.locks.set( + channelId, + run.then( + () => undefined, + () => undefined, + ), + ); + return run; + } +} diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/server/types.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/server/types.ts new file mode 100644 index 0000000000..f259821436 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/server/types.ts @@ -0,0 +1,135 @@ +import type { BatchChannelConfig, BatchProof } from "../types"; + +export type BatchChannelStatus = "open" | "closing" | "distributed"; + +/** Ceiling held while a verified handler runs, keyed by reservation id on {@link ChannelState}. */ +export type ChannelReservation = { + ceiling: bigint; + expiresAt: number; + requestId?: string | undefined; + kind: "client" | "server" | "close"; +}; + +/** Server-held state for a single channel, keyed by `channelId`. */ +export interface ChannelState { + /** Channel PDA (base58). */ + channelId: string; + /** Channel payer / depositor (base58). */ + payer: string; + /** Final payment receiver (base58). */ + receiver: string; + /** Zero-share program payee and transaction sponsor. */ + feePayer: string; + /** SPL mint (base58). */ + mint: string; + /** Token program id for the mint (base58). */ + tokenProgram: string; + /** Voucher signer = the client (base58). */ + payerAuthorizer: string; + /** Optional server close authorizer from the challenge. */ + receiverAuthorizer?: string | undefined; + /** Forced-close grace period. */ + withdrawDelay: number; + /** Channel PDA salt and open-slot seed. */ + salt: bigint; + openSlot: bigint; + /** On-chain escrow deposit (base units). */ + deposit: bigint; + /** Highest accepted off-chain cumulative (the watermark). */ + chargedCumulativeAmount: bigint; + /** Highest signed voucher watermark, committed after serving. */ + signedMaxClaimable: bigint; + /** On-chain settled watermark (advanced by `settleBatch`). */ + settled: bigint; + /** Cumulative distributed on-chain (base units). */ + payoutWatermark: bigint; + /** Wall-clock time when facilitator verification last refreshed onchain state. */ + onchainSyncedAt?: number | undefined; + /** Channel lifecycle status. */ + status: BatchChannelStatus; + /** When a forced/cooperative close was requested (Unix seconds), if any. */ + closeRequestedAt?: number | undefined; + /** The highest accepted voucher's signature (base58), for redemption. */ + highestVoucherSignature?: string | undefined; + /** The highest accepted voucher's expiry (Unix seconds). */ + highestVoucherExpiresAt?: number | undefined; + /** Canonical wire configuration retained for response and channel binding. */ + channelConfig: BatchChannelConfig; + /** The broadcast `open` signature, returned in the deposit settlement response. */ + openSignature?: string | undefined; + /** Broadcast signature for the payer-forced request_close transition. */ + closeSignature?: string | undefined; + /** Request ceilings reserved while verified handlers execute. */ + reservations?: Record | undefined; +} + +/** + * Channel store contract. `update` performs an atomic read-modify-write so that + * concurrent voucher acceptance for the same channel is serialized. + */ +export interface ChannelStore { + /** + * Fetch a channel's state. + * + * @param channelId - Channel PDA (base58) + * @returns The state, or undefined if unknown + */ + get(channelId: string): Promise; + + /** + * Every channel this store holds. + * + * Optional: only a redemption worker needs to enumerate, and a store built + * for request serving alone can leave it out. Implementations may return a + * weakly-consistent snapshot — a worker reconciles each channel against the + * chain anyway. + */ + list?(): Promise; + + /** + * Insert or overwrite a channel's state. + * + * @param state - The channel state + */ + put(state: ChannelState): Promise; + + /** + * Atomically read-modify-write a channel under a per-channel lock. The updater + * runs with exclusive access; concurrent updates to the same channel queue. + * + * @param channelId - Channel PDA (base58) + * @param updater - Receives the current state (or undefined) and returns the new state + * @returns The written state + */ + update( + channelId: string, + updater: (current: ChannelState | undefined) => ChannelState | Promise, + ): Promise; +} + +/** Per-request bookkeeping the server keeps from verify until settle. */ +export type RequestContext = { + channelId: string; + /** Deposit, voucher, or authorization. Refund does not fit this union. */ + proof?: BatchProof; + /** Client-signed cumulative amount, when the payer supplies the voucher. */ + cumulative?: bigint; + /** Maximum charge advertised before the handler runs. */ + ceiling?: bigint; + requestId?: string; + pendingId?: string; + topUp?: boolean; + /** + * Set when local state is absent or stale, so the cumulative rule must be + * applied after the facilitator refreshes the onchain snapshot. + */ + requiresCumulativeCheck?: boolean; +}; + +/** A channel snapshot a facilitator confirmed against the chain. */ +export type VerifiedChannelState = { + channelId?: string | undefined; + balance?: bigint | undefined; + totalClaimed: bigint; + withdrawRequestedAt: number; +}; diff --git a/typescript/packages/mechanisms/svm/src/batch-settlement/types.ts b/typescript/packages/mechanisms/svm/src/batch-settlement/types.ts new file mode 100644 index 0000000000..1aface6a1a --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/batch-settlement/types.ts @@ -0,0 +1,360 @@ +/* eslint-disable jsdoc/require-jsdoc */ +/** Wire types for the SVM `batch-settlement` scheme. */ + +import { BatchError } from "./errors"; + +export { BATCH_SETTLEMENT_SCHEME } from "./constants"; + +export type BatchExtra = { + paymentFlow?: "authorization" | undefined; + minDeposit?: string | undefined; + feePayer: string; + receiverAuthorizer: string; + withdrawDelay: number; + tokenProgram: string; + memo?: string | undefined; + recentBlockhash?: string | undefined; + recentSlot?: number | undefined; + channelState?: BatchChannelState | undefined; + voucherState?: BatchVoucherState | undefined; + voucherSigner?: BatchVoucherSigner | undefined; + operator?: string | undefined; + /** + * Facilitator idle window in seconds, copied from `/supported`. After this + * long with no facilitator-visible lifecycle activity an `Open` channel may + * be abandon-closed at its onchain settled watermark. Absent means the + * facilitator does not idle-close. + */ + maxIdleSecs?: number | undefined; +}; + +export type BatchVoucherSigner = "client" | "server"; + +export type BatchAuthorization = { + type: "proof"; + channelId: string; + payer: string; + requestId: string; + authorizedAmount: string; + expiresAt: number; + signature: string; +}; + +/** + * The signed voucher proof a corrective 402 carries, so the client can adopt a + * new cumulative base without taking the server's word for it. + * + * This is not a {@link BatchVoucher}: it names only the cumulative amount the + * server holds a signature for, and the client rederives the channel id it + * verifies against. See the scheme spec section 4.6. + */ +export type BatchVoucherState = { + /** Cumulative amount the server holds a client signature for. */ + signedMaxClaimable: string; + /** Expiry in the signed message; always `0` in this scheme. */ + expiresAt: number; + /** Base58 Ed25519 signature over the 50-byte voucher message. */ + signature: string; +}; + +export type BatchChannelConfig = { + payer: string; + payerAuthorizer: string; + receiver: string; + receiverAuthorizer: string; + token: string; + withdrawDelay: number; + salt: string; + openSlot: number; + voucherSigner?: BatchVoucherSigner | undefined; +}; + +export type BatchVoucher = { + channelId: string; + maxClaimableAmount: string; + expiresAt: number; + signature: string; +}; + +export type CloseAuthorization = { + validBefore: number; + signature: string; +}; + +export type BatchDepositPayload = { + type: "deposit"; + channelConfig: BatchChannelConfig; + voucher?: BatchVoucher | undefined; + authorization?: BatchAuthorization | undefined; + deposit: { + amount: string; + transaction: string; + }; +}; + +export type BatchVoucherPayload = { + type: "voucher"; + channelConfig: BatchChannelConfig; + voucher: BatchVoucher; +}; + +export type BatchAuthorizationPayload = { + type: "authorization"; + channelConfig: BatchChannelConfig; + authorization: BatchAuthorization; +}; + +/** Client-signed voucher or server-mode payer authorization. */ +export type BatchProof = + | { signer: "client"; voucher: BatchVoucher } + | { signer: "server"; authorization: BatchAuthorization }; + +export function proofOf( + payload: BatchDepositPayload | BatchVoucherPayload | BatchAuthorizationPayload, +): BatchProof { + switch (payload.type) { + case "voucher": + return { signer: "client", voucher: payload.voucher }; + case "authorization": + return { signer: "server", authorization: payload.authorization }; + case "deposit": + if (payload.voucher !== undefined) return { signer: "client", voucher: payload.voucher }; + if (payload.authorization !== undefined) { + return { signer: "server", authorization: payload.authorization }; + } + throw new Error(BatchError.VOUCHER_SIGNATURE); + default: { + const unexpected: never = payload; + throw new Error(String(unexpected)); + } + } +} + +/** + * Client mode: payer-signed `voucher` at the accepted cumulative. Server mode: + * payer `authorization` with `authorizedAmount` `"0"`; the server injects the + * operator voucher and `closeAuthorization` before settle. + */ +export type BatchRefundPayload = { + type: "refund"; + channelConfig: BatchChannelConfig; + voucher?: BatchVoucher | undefined; + authorization?: BatchAuthorization | undefined; + transaction?: string | undefined; + closeAuthorization?: CloseAuthorization | undefined; +}; + +export type BatchPayload = + | BatchDepositPayload + | BatchVoucherPayload + | BatchAuthorizationPayload + | BatchRefundPayload; + +/** + * One channel in a server-authored `claim`: the same `channelId` + + * `channelConfig` entry shape as `settle` and `seal`, carrying the latest + * accepted voucher as a standard {@link BatchVoucher}. + */ +export type BatchVoucherClaim = { + channelId: string; + channelConfig: BatchChannelConfig; + voucher: BatchVoucher; +}; + +export type BatchClaimPayload = { + type: "claim"; + claims: BatchVoucherClaim[]; +}; + +export type BatchSettlePayload = { + type: "settle"; + channels: { channelId: string; channelConfig: BatchChannelConfig }[]; +}; + +/** + * Server-authored cooperative close of a `Closing` channel: apply the server's + * latest accepted voucher with `settle_and_seal` and pay out with a sealed + * `distribute` before the payer's grace period ends (spec 4.5). + */ +export type BatchSealPayload = { + type: "seal"; + channelId: string; + channelConfig: BatchChannelConfig; + /** Latest accepted voucher; its cumulative becomes the final settled watermark. */ + voucher: BatchVoucher; + /** + * Required when the channel's receiver authorizer is a server key. Omitted + * when the facilitator is that authorizer and authenticates the caller. + */ + closeAuthorization?: CloseAuthorization | undefined; +}; + +export type BatchFacilitatorPayload = + | BatchPayload + | BatchClaimPayload + | BatchSettlePayload + | BatchSealPayload; + +export type BatchChannelState = { + channelId: string; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + chargedCumulativeAmount?: string | undefined; +}; + +export function isBatchVoucher(value: unknown): value is BatchVoucher { + if (!isRecord(value)) return false; + return ( + typeof value.channelId === "string" && + typeof value.maxClaimableAmount === "string" && + typeof value.expiresAt === "number" && + typeof value.signature === "string" + ); +} + +export function isBatchChannelConfig(value: unknown): value is BatchChannelConfig { + if (!isRecord(value)) return false; + return ( + typeof value.payer === "string" && + typeof value.payerAuthorizer === "string" && + typeof value.receiver === "string" && + typeof value.receiverAuthorizer === "string" && + typeof value.token === "string" && + typeof value.withdrawDelay === "number" && + typeof value.salt === "string" && + typeof value.openSlot === "number" && + (value.voucherSigner === undefined || + value.voucherSigner === "client" || + value.voucherSigner === "server") + ); +} + +export function isBatchPayload(value: unknown): value is BatchPayload { + if (!isRecord(value) || !isBatchChannelConfig(value.channelConfig)) return false; + switch (value.type) { + case "deposit": + if (!isRecord(value.deposit)) return false; + if ( + typeof value.deposit.amount !== "string" || + typeof value.deposit.transaction !== "string" + ) { + return false; + } + return value.channelConfig.voucherSigner === "server" + ? value.voucher === undefined && + isBatchAuthorization(value.authorization) && + value.requestId === undefined && + value.maxClaimableAmount === undefined + : isBatchVoucher(value.voucher) && + value.authorization === undefined && + value.requestId === undefined && + value.maxClaimableAmount === undefined; + case "voucher": + return value.channelConfig.voucherSigner !== "server" && isBatchVoucher(value.voucher); + case "authorization": + return ( + value.channelConfig.voucherSigner === "server" && + isBatchAuthorization(value.authorization) && + value.requestId === undefined && + value.maxClaimableAmount === undefined + ); + case "refund": { + const txOk = value.transaction === undefined || typeof value.transaction === "string"; + const closeOk = + value.closeAuthorization === undefined || isCloseAuthorization(value.closeAuthorization); + const signer = value.channelConfig.voucherSigner ?? "client"; + if (signer === "server") { + const authOk = + value.authorization === undefined || + (isBatchAuthorization(value.authorization) && + value.authorization.authorizedAmount === "0"); + if (isBatchVoucher(value.voucher)) { + return authOk && txOk && closeOk; + } + return ( + value.voucher === undefined && + isBatchAuthorization(value.authorization) && + value.authorization.authorizedAmount === "0" && + txOk && + closeOk + ); + } + return isBatchVoucher(value.voucher) && value.authorization === undefined && txOk && closeOk; + } + default: + return false; + } +} + +function isBatchAuthorization(value: unknown): value is BatchAuthorization { + return ( + isRecord(value) && + value.type === "proof" && + typeof value.channelId === "string" && + typeof value.payer === "string" && + typeof value.requestId === "string" && + value.requestId.length > 0 && + typeof value.authorizedAmount === "string" && + /^\d+$/.test(value.authorizedAmount) && + typeof value.expiresAt === "number" && + Number.isSafeInteger(value.expiresAt) && + value.expiresAt > 0 && + typeof value.signature === "string" + ); +} + +export function isBatchFacilitatorPayload(value: unknown): value is BatchFacilitatorPayload { + if (isBatchPayload(value)) return true; + if (!isRecord(value)) return false; + if (value.type === "claim") { + return ( + Array.isArray(value.claims) && + value.claims.length > 0 && + value.claims.length <= 4 && + value.claims.every(isBatchVoucherClaim) + ); + } + if (value.type === "seal") { + return ( + typeof value.channelId === "string" && + isBatchChannelConfig(value.channelConfig) && + isBatchVoucher(value.voucher) && + (value.closeAuthorization === undefined || isCloseAuthorization(value.closeAuthorization)) + ); + } + return ( + value.type === "settle" && + Array.isArray(value.channels) && + value.channels.length > 0 && + value.channels.every( + item => + isRecord(item) && + typeof item.channelId === "string" && + isBatchChannelConfig(item.channelConfig), + ) + ); +} + +function isBatchVoucherClaim(value: unknown): value is BatchVoucherClaim { + return ( + isRecord(value) && + typeof value.channelId === "string" && + isBatchChannelConfig(value.channelConfig) && + isBatchVoucher(value.voucher) + ); +} + +function isCloseAuthorization(value: unknown): value is CloseAuthorization { + return ( + isRecord(value) && + typeof value.validBefore === "number" && + Number.isSafeInteger(value.validBefore) && + value.validBefore > 0 && + typeof value.signature === "string" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/typescript/packages/mechanisms/svm/src/index.ts b/typescript/packages/mechanisms/svm/src/index.ts index db3ddcf7e3..18f08c492d 100644 --- a/typescript/packages/mechanisms/svm/src/index.ts +++ b/typescript/packages/mechanisms/svm/src/index.ts @@ -8,6 +8,7 @@ export { ExactSvmScheme } from "./exact"; export type { ExactSvmSchemeOptions } from "./exact/facilitator/scheme"; export { UptoSvmScheme } from "./upto"; +export { BatchSvmScheme } from "./batch-settlement"; // Export smart wallet verification helpers export { @@ -42,6 +43,51 @@ export type { export type { ExactSvmPayloadV1, ExactSvmPayloadV2, UptoSvmPayloadV2 } from "./types"; export { isUptoSvmPayload } from "./types"; +export { + discoverChannelsByRentPayer, + CHANNEL_ACCOUNT_SIZE, + CHANNEL_RENT_PAYER_OFFSET, +} from "./payment-channels/discovery"; +export type { DiscoveredChannel } from "./payment-channels/discovery"; + +// Export batch-settlement wire types +export type { + BatchExtra, + BatchAuthorization, + BatchAuthorizationPayload, + BatchVoucherSigner, + BatchVoucher, + CloseAuthorization, + BatchChannelConfig, + BatchPayload, + BatchDepositPayload, + BatchVoucherPayload, + BatchRefundPayload, + BatchClaimPayload, + BatchSettlePayload, + BatchVoucherClaim, + BatchFacilitatorPayload, + BatchChannelState, +} from "./batch-settlement/types"; +export { + encodeBatchAuthorizationMessage, + signBatchAuthorization, + verifyBatchAuthorization, +} from "./batch-settlement/authorization"; +export { + encodeCloseAuthorizationDigest, + signCloseAuthorization, + verifyCloseAuthorization, + type CloseAuthorizationBinding, +} from "./batch-settlement/closeAuthorization"; +export { + BATCH_SETTLEMENT_SCHEME, + isBatchChannelConfig, + isBatchPayload, + isBatchFacilitatorPayload, + isBatchVoucher, +} from "./batch-settlement/types"; + // Export settlement cache (shared across V1/V2 facilitator instances) export { SettlementCache } from "./settlement-cache"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/close.ts b/typescript/packages/mechanisms/svm/src/payment-channels/close.ts new file mode 100644 index 0000000000..9a0667b0d9 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/close.ts @@ -0,0 +1,307 @@ +/** Client construction and facilitator verification for payer-forced channel close. */ + +import { + AccountRole, + appendTransactionMessageInstructions, + type Address, + address, + type Blockhash, + createTransactionMessage, + getBase58Encoder, + getBase64Codec, + getBase64EncodedWireTransaction, + getCompiledTransactionMessageDecoder, + getTransactionDecoder, + isSignerRole, + isWritableRole, + partiallySignTransactionMessageWithSigners, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type TransactionSigner, +} from "@solana/kit"; + +import { + COMPUTE_BUDGET_PROGRAM_ADDRESS, + LIGHTHOUSE_PROGRAM_ADDRESS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_MEMO_BYTES, + MEMO_PROGRAM_ADDRESS, +} from "../constants"; +import { + getRequestCloseInstruction, + REQUEST_CLOSE_DISCRIMINATOR, +} from "./generated/instructions/requestClose"; +import { OPEN_MAX_COMPUTE_UNIT_LIMIT } from "./open"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "./onchain"; +import { verifyEd25519Signature } from "./voucher"; + +const MAX_LIGHTHOUSE_INSTRUCTIONS = 3; + +type CompiledMessage = { + addressTableLookups?: readonly unknown[]; + header: { + numReadonlyNonSignerAccounts: number; + numReadonlySignerAccounts: number; + numSignerAccounts: number; + }; + instructions: readonly { + accountIndices?: readonly number[]; + data?: Uint8Array | undefined; + programAddressIndex: number; + }[]; + staticAccounts: readonly string[]; +}; + +export interface BuildRequestCloseArgs { + payer: TransactionSigner; + feePayer: string; + channelId: string; + blockhash: { blockhash: string; lastValidBlockHeight: bigint }; + memo?: string | undefined; + programId?: string | undefined; +} + +/** Build a payer-signed `request_close` transaction with an empty sponsor signature slot. */ +export async function buildRequestCloseTransaction(args: BuildRequestCloseArgs): Promise { + const programAddress = address(args.programId ?? PAYMENT_CHANNELS_PROGRAM_ID); + const requestClose = getRequestCloseInstruction( + { channel: address(args.channelId), payer: args.payer }, + { programAddress }, + ); + const memoData = requestMemo(args.memo); + const memo = { + programAddress: MEMO_PROGRAM_ADDRESS as Address, + accounts: [] as const, + data: memoData, + }; + const message = pipe( + createTransactionMessage({ version: 0 }), + value => setTransactionMessageFeePayer(address(args.feePayer), value), + value => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: args.blockhash.blockhash as Blockhash, + lastValidBlockHeight: args.blockhash.lastValidBlockHeight, + }, + value, + ), + value => appendTransactionMessageInstructions([requestClose, memo], value), + ); + const signed = await partiallySignTransactionMessageWithSigners(message); + return getBase64EncodedWireTransaction(signed); +} + +export interface VerifyRequestCloseExpected { + payer: string; + feePayer: string; + channelId: string; + memo?: string | undefined; + programId?: string | undefined; + maxComputeUnits?: number | undefined; + maxPriorityFeeMicroLamports?: number | undefined; +} + +/** + * Verify the complete sponsor-bound transaction envelope for `request_close`. + * The sponsor may authorize only the fee for one canonical close instruction. + */ +// The validation is intentionally exhaustive and linear so every permitted +// instruction, account, signer, and privilege remains visible in one audit path. +// eslint-disable-next-line complexity, sonarjs/cognitive-complexity +export async function verifyRequestCloseTransaction( + transactionBase64: string, + expected: VerifyRequestCloseExpected, +): Promise { + if (expected.payer === expected.feePayer) { + throw new Error("verifyRequestCloseTransaction: payer must differ from feePayer"); + } + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode( + decoded.messageBytes, + ) as unknown as CompiledMessage; + if (message.addressTableLookups && message.addressTableLookups.length > 0) { + throw new Error("verifyRequestCloseTransaction: address lookup tables are not permitted"); + } + + const signers = message.staticAccounts.slice(0, message.header.numSignerAccounts); + const expectedSigners = new Set([expected.feePayer, expected.payer]); + if ( + signers.length !== expectedSigners.size || + signers.some(value => !expectedSigners.has(value)) + ) { + throw new Error("verifyRequestCloseTransaction: required signers must be payer and feePayer"); + } + if (message.staticAccounts[0] !== expected.feePayer) { + throw new Error("verifyRequestCloseTransaction: transaction fee payer mismatch"); + } + + const payerSignature = decoded.signatures[expected.payer as Address]; + if (!payerSignature) { + throw new Error("verifyRequestCloseTransaction: missing payer signature"); + } + const signatureValid = await verifyEd25519Signature({ + message: decoded.messageBytes as unknown as Uint8Array, + publicKey: getBase58Encoder().encode(expected.payer) as Uint8Array, + signature: payerSignature as unknown as Uint8Array, + }); + if (!signatureValid) { + throw new Error("verifyRequestCloseTransaction: invalid payer signature"); + } + + const programId = expected.programId ?? PAYMENT_CHANNELS_PROGRAM_ID; + const maxComputeUnits = Math.min( + expected.maxComputeUnits ?? OPEN_MAX_COMPUTE_UNIT_LIMIT, + OPEN_MAX_COMPUTE_UNIT_LIMIT, + ); + const maxPriorityFee = Math.min( + expected.maxPriorityFeeMicroLamports ?? MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + ); + let phase: "prefix" | "close" | "suffix" = "prefix"; + let computeLimitSeen = false; + let computePriceSeen = false; + let closeSeen = false; + let memoSeen = false; + let lighthouseCount = 0; + + for (const ix of message.instructions) { + const invokedProgram = message.staticAccounts[ix.programAddressIndex]; + if (phase === "prefix" && invokedProgram === COMPUTE_BUDGET_PROGRAM_ADDRESS) { + const data = ix.data; + if (!data || data.length === 0 || (ix.accountIndices?.length ?? 0) !== 0) { + throw new Error("invalid Compute Budget instruction"); + } + if (data[0] === 2) { + if (computeLimitSeen || computePriceSeen || data.length !== 5) { + throw new Error("invalid SetComputeUnitLimit instruction"); + } + const units = new DataView(data.buffer, data.byteOffset + 1, 4).getUint32(0, true); + if (units > maxComputeUnits) throw new Error("compute unit limit exceeds sponsor cap"); + computeLimitSeen = true; + continue; + } + if (data[0] === 3) { + if (computePriceSeen || data.length !== 9) { + throw new Error("invalid SetComputeUnitPrice instruction"); + } + const price = new DataView(data.buffer, data.byteOffset + 1, 8).getBigUint64(0, true); + if (price > BigInt(maxPriorityFee)) + throw new Error("compute unit price exceeds sponsor cap"); + computePriceSeen = true; + continue; + } + throw new Error("unsupported Compute Budget instruction"); + } + + if (!closeSeen && invokedProgram === programId) { + phase = "close"; + const data = ix.data; + const indices = ix.accountIndices ?? []; + if (!data || data.length !== 1 || data[0] !== REQUEST_CLOSE_DISCRIMINATOR) { + throw new Error("verifyRequestCloseTransaction: expected request_close discriminator"); + } + if (indices.length !== 2) { + throw new Error("verifyRequestCloseTransaction: request_close must have two accounts"); + } + const payerIndex = indices[0]; + const channelIndex = indices[1]; + if ( + payerIndex === undefined || + channelIndex === undefined || + message.staticAccounts[payerIndex] !== expected.payer || + message.staticAccounts[channelIndex] !== expected.channelId + ) { + throw new Error("verifyRequestCloseTransaction: request_close account binding mismatch"); + } + const payerRole = staticAccountRole(message, payerIndex); + const channelRole = staticAccountRole(message, channelIndex); + if ( + !isSignerRole(payerRole) || + isWritableRole(payerRole) || + !isWritableRole(channelRole) || + isSignerRole(channelRole) + ) { + throw new Error("verifyRequestCloseTransaction: request_close account privileges mismatch"); + } + closeSeen = true; + continue; + } + + if (!closeSeen) { + throw new Error("verifyRequestCloseTransaction: unexpected instruction before request_close"); + } + phase = "suffix"; + if (invokedProgram === MEMO_PROGRAM_ADDRESS) { + if (memoSeen || (ix.accountIndices?.length ?? 0) !== 0) { + throw new Error("verifyRequestCloseTransaction: invalid Memo suffix"); + } + memoSeen = true; + const memoData = ix.data ?? new Uint8Array(); + if (memoData.byteLength > MAX_MEMO_BYTES) { + throw new Error("verifyRequestCloseTransaction: memo exceeds the byte limit"); + } + if (expected.memo !== undefined) { + const actual = new TextDecoder().decode(memoData); + if (actual !== expected.memo) + throw new Error("verifyRequestCloseTransaction: memo mismatch"); + } else { + const actual = new TextDecoder().decode(memoData); + if (!/^[0-9a-f]{32,}$/i.test(actual)) { + throw new Error("verifyRequestCloseTransaction: nonce memo must be hexadecimal"); + } + } + continue; + } + if (invokedProgram === LIGHTHOUSE_PROGRAM_ADDRESS) { + lighthouseCount += 1; + if (lighthouseCount > MAX_LIGHTHOUSE_INSTRUCTIONS) { + throw new Error("verifyRequestCloseTransaction: too many Lighthouse instructions"); + } + rejectFeePayerReference(ix, message.staticAccounts, expected.feePayer); + continue; + } + throw new Error("verifyRequestCloseTransaction: unsupported suffix instruction"); + } + if (!closeSeen) throw new Error("verifyRequestCloseTransaction: missing request_close"); +} + +function requestMemo(value: string | undefined): Uint8Array { + if (value !== undefined) { + const encoded = new TextEncoder().encode(value); + if (encoded.byteLength > MAX_MEMO_BYTES) { + throw new Error(`extra.memo exceeds maximum ${MAX_MEMO_BYTES} bytes`); + } + return encoded; + } + const nonce = globalThis.crypto.getRandomValues(new Uint8Array(16)); + return new TextEncoder().encode( + Array.from(nonce) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""), + ); +} + +function rejectFeePayerReference( + ix: { accountIndices?: readonly number[]; programAddressIndex: number }, + staticAccounts: readonly string[], + feePayer: string, +): void { + if (staticAccounts[ix.programAddressIndex] === feePayer) { + throw new Error("verifyRequestCloseTransaction: feePayer must not be an invoked program"); + } + if ((ix.accountIndices ?? []).some(index => staticAccounts[index] === feePayer)) { + throw new Error("verifyRequestCloseTransaction: feePayer must not be a suffix account"); + } +} + +function staticAccountRole(message: CompiledMessage, index: number): AccountRole { + const { header } = message; + const writableSigners = header.numSignerAccounts - header.numReadonlySignerAccounts; + const writableNonSigners = + message.staticAccounts.length - header.numSignerAccounts - header.numReadonlyNonSignerAccounts; + if (index < writableSigners) return AccountRole.WRITABLE_SIGNER; + if (index < header.numSignerAccounts) return AccountRole.READONLY_SIGNER; + if (index < header.numSignerAccounts + writableNonSigners) return AccountRole.WRITABLE; + return AccountRole.READONLY; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/commitments.ts b/typescript/packages/mechanisms/svm/src/payment-channels/commitments.ts new file mode 100644 index 0000000000..4f3a10cb40 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/commitments.ts @@ -0,0 +1,25 @@ +/** Denominator a `ChannelSplit.bps` is measured against; 10_000 = 100%. */ +export const BASIS_POINTS_DENOMINATOR = 10_000; + +/** + * Commitment for reading account state the caller must act on. Opens are + * confirmed at this level, and the RPC default (`finalized`) lags a fresh open + * by seconds, reporting a live channel as missing. + * + * The Go SDK reads the same class of state at the same level, so both + * facilitators judge the same channel identically. + */ +export const STATE_COMMITMENT = "confirmed" as const; + +/** + * Commitment for reading the slot used as an `openSlot` anchor. Clients pin + * `openSlot` at this level to keep `openSlot <= clock.slot` when the open lands, + * so verify and the reclaim gate must judge it in the same frame. + */ +export const SLOT_COMMITMENT = "finalized" as const; + +/** + * Commitment for reading transaction-lifetime blockhashes. A finalized hash + * cannot be dropped by a fork before the transaction lands. + */ +export const BLOCKHASH_COMMITMENT = "finalized" as const; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts b/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts index 95f199198c..c827cd9e1c 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/discovery.ts @@ -10,6 +10,7 @@ import { address, type Address, type Base58EncodedBytes, getBase64Encoder } from import type { FacilitatorSvmSigner } from "../signer"; import type { Channel } from "./generated/accounts/channel"; +import { AccountDiscriminator } from "./generated/types/accountDiscriminator"; import { getChannelDecoder } from "./generated/accounts/channel"; import { PAYMENT_CHANNELS_PROGRAM_ID } from "./onchain"; import { findPaymentChannelPda } from "./open"; @@ -20,6 +21,14 @@ export const CHANNEL_ACCOUNT_SIZE = 256n; /** `Channel.rent_payer` byte offset; also the getProgramAccounts memcmp offset. */ export const CHANNEL_RENT_PAYER_OFFSET = 216n; +/** + * Byte offset of `Channel.payer`, for discovering the channels a wallet funded. + * + * Derived from the account layout: the four preceding pubkeys sit after the + * 88-byte header, and `rent_payer` at 216 is this offset plus four addresses. + */ +export const CHANNEL_PAYER_OFFSET = 88n; + /** A channel account found onchain and validated independent of offchain metadata. */ export interface DiscoveredChannel { channelId: string; @@ -41,6 +50,75 @@ export interface DiscoveredChannel { * @param programId - Optional payment-channels program id override * @returns Validated discovered channels */ +/** + * A `getProgramAccounts` scan, however the caller reaches the network. + * + * Lets a facilitator scan through its signer and a client through a plain RPC + * client without either owning the validation that follows. + */ +export type ProgramAccountScan = ( + programId: string, + filters: readonly unknown[], +) => Promise; + +/** The scan filters that select channels by one 32-byte address field. */ +export function channelAddressFilters(value: string, offset: bigint): readonly unknown[] { + return [ + { dataSize: CHANNEL_ACCOUNT_SIZE }, + { + memcmp: { + bytes: value as Base58EncodedBytes, + encoding: "base58", + offset, + }, + }, + ]; +} + +/** + * Run a channel scan and keep only the rows the chain actually vouches for. + * + * Every row is decoded and its PDA rederived from its own fields, because a + * `getProgramAccounts` filter result is never trusted on its own: an account + * can carry any bytes at the filtered offset. + */ +export async function discoverChannels( + scan: ProgramAccountScan, + filters: readonly unknown[], + programId?: string, +): Promise { + const program = address(programId ?? PAYMENT_CHANNELS_PROGRAM_ID); + const rows = await scan(program.toString(), filters); + const discovered: DiscoveredChannel[] = []; + for (const row of rows) { + const validated = await validateDiscoveredAccount( + row.pubkey, + row.account.owner, + row.account.data[0], + program, + ); + if (validated) discovered.push(validated); + } + return discovered; +} + +/** + * Discover the channels a wallet opened and funded, for a client that has to + * rebuild its own channel list. + * + * @param scan - How to reach `getProgramAccounts` + * @param payer - The channel `payer` to scan for + * @param programId - Payment-channels program override + * @returns Every channel whose PDA rederives from its own fields + */ +export async function discoverChannelsByPayer( + scan: ProgramAccountScan, + payer: string, + programId?: string, +): Promise { + return discoverChannels(scan, channelAddressFilters(payer, CHANNEL_PAYER_OFFSET), programId); +} + export async function discoverChannelsByRentPayer( signer: Pick, network: string, @@ -53,33 +131,17 @@ export async function discoverChannelsByRentPayer( "Use toFacilitatorSvmSigner() which provides all required methods.", ); } - const program = address(programId ?? PAYMENT_CHANNELS_PROGRAM_ID); - const results = await signer.getProgramAccounts(network, program.toString(), { - commitment: "confirmed", - encoding: "base64", - filters: [ - { dataSize: CHANNEL_ACCOUNT_SIZE }, - { - memcmp: { - bytes: rentPayer as Base58EncodedBytes, - encoding: "base58", - offset: CHANNEL_RENT_PAYER_OFFSET, - }, - }, - ], - }); - - const discovered: DiscoveredChannel[] = []; - for (const result of results) { - const validated = await validateDiscoveredAccount( - result.pubkey, - result.account.owner, - result.account.data[0], - program, - ); - if (validated) discovered.push(validated); - } - return discovered; + const scan: ProgramAccountScan = (programAddress, filters) => + signer.getProgramAccounts!(network, programAddress, { + commitment: "confirmed", + encoding: "base64", + filters, + }); + return discoverChannels( + scan, + channelAddressFilters(rentPayer, CHANNEL_RENT_PAYER_OFFSET), + programId, + ); } /** @@ -111,7 +173,7 @@ async function validateDiscoveredAccount( } catch { return undefined; } - if (channel.discriminator !== 1) return undefined; + if (channel.discriminator !== AccountDiscriminator.Channel) return undefined; const derived = await findPaymentChannelPda({ authorizedSigner: channel.authorizedSigner, diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts b/typescript/packages/mechanisms/svm/src/payment-channels/facilitator.ts similarity index 59% rename from typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts rename to typescript/packages/mechanisms/svm/src/payment-channels/facilitator.ts index 7f7888dbda..a4127b92df 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/channel.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/facilitator.ts @@ -1,5 +1,5 @@ /** - * Channel-flow glue for the `upto` facilitator: co-signing, broadcasting the + * Shared payment-channel facilitator mechanics: co-signing, broadcasting a * client `open`, simulating settlement readiness (atomic open + settle + * distribute before open), and submitting settle+distribute. * @@ -23,6 +23,7 @@ import { createTransactionMessage, decompileTransactionMessage, getBase58Encoder, + getSignatureFromTransaction, getBase64Codec, getBase64EncodedWireTransaction, getCompiledTransactionMessageDecoder, @@ -41,22 +42,20 @@ import { import { COMPUTE_BUDGET_PROGRAM_ADDRESS, DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS, -} from "../../constants"; -import { fetchMaybeChannel, type Channel } from "../../payment-channels/generated/accounts/channel"; +} from "../constants"; +import { fetchMaybeChannel, type Channel } from "./generated/accounts/channel"; +import { AccountDiscriminator } from "./generated/types/accountDiscriminator"; +import { ChannelStatus } from "./generated/types/channelStatus"; import { buildDistributeInstruction, buildSettleAndSealInstructions, type ServerInstruction, -} from "../../payment-channels/onchain"; -import type { ChannelSplit } from "../../payment-channels/open"; -import type { FacilitatorSvmSigner } from "../../signer"; -import { TransactionOnchainFailureError } from "../../utils"; -import { STATE_COMMITMENT } from "../shared"; -import type { UptoFacilitatorSigner } from "./signer"; - -/** Payment-channels `AccountDiscriminator::Channel` (byte 0 is reserved for uninitialized accounts). */ -const CHANNEL_ACCOUNT_DISCRIMINATOR = 1; -const CHANNEL_STATUS_OPEN = 0; +} from "./onchain"; +import type { ChannelSplit } from "./open"; +import type { FacilitatorSvmSigner } from "../signer"; +import { BLOCKHASH_COMMITMENT, STATE_COMMITMENT } from "./commitments"; +import { createRpcClient, TransactionOnchainFailureError } from "../utils"; + /** Solana per-transaction compute-unit maximum. */ const MAX_TRANSACTION_COMPUTE_UNITS = 1_400_000; /** Compute-unit limit for facilitator-built sims; sims raise the limit to the @@ -114,56 +113,26 @@ export function reclaimComputeUnitLimit(channelCount: number): number { } /** Signer capable of signing Solana transactions and raw Ed25519 messages. */ -export type UptoSvmSigner = TransactionSigner & MessagePartialSigner; +export type PaymentChannelSvmSigner = TransactionSigner & MessagePartialSigner; -/** Placeholder blockhash for deposit composite sims (`replaceRecentBlockhash: true`). */ -const SIM_PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111" as Blockhash; +/** RPC client shape used by the channel helpers. */ +export type ChannelRpc = ReturnType; -/** - * Kit-compatible RPC adapter for generated account fetch helpers. - * - * @param signer - Upto facilitator signer - * @param network - CAIP-2 network identifier - * @returns Minimal RPC surface for {@link fetchMaybeChannel} - */ -export function accountFetchRpc( - signer: UptoFacilitatorSigner, - network: string, -): Parameters[0] { - return { - getAccountInfo: ( - accountAddress: Address, - config?: { commitment?: string; encoding?: string }, - ) => ({ - send: async () => ({ - context: { slot: 0n }, - value: await signer.getAccountInfo(accountAddress.toString(), network, { - commitment: config?.commitment, - encoding: config?.encoding, - }), - }), - }), - } as Parameters[0]; -} +/** Placeholder blockhash for facilitator-signer deposit sims (`replaceRecentBlockhash: true`). */ +const SIM_PLACEHOLDER_BLOCKHASH = "11111111111111111111111111111111" as Blockhash; /** * Whether the channel account already exists onchain (open already broadcast). * - * @param signer - Facilitator signer with read RPC - * @param network - CAIP-2 network identifier + * @param rpc - The RPC client * @param channelId - Channel PDA (base58) * @returns Whether the account exists */ -export async function channelExists( - signer: UptoFacilitatorSigner, - network: string, - channelId: string, -): Promise { - const info = await signer.getAccountInfo(channelId, network, { - commitment: STATE_COMMITMENT, - encoding: "base64", - }); - return info !== null; +export async function channelExists(rpc: ChannelRpc, channelId: string): Promise { + const info = await rpc + .getAccountInfo(address(channelId), { commitment: STATE_COMMITMENT, encoding: "base64" }) + .send(); + return info.value !== null; } /** @@ -232,22 +201,19 @@ export interface VerifiedOpenChannel { /** * Fetch and bind the confirmed channel account before resource execution. * - * @param signer - Facilitator signer with read RPC - * @param network - CAIP-2 network identifier + * @param rpc - RPC client used to read the channel * @param channelId - Channel PDA * @param expected - Challenge-bound channel terms * @param policy - Optional re-read attempt/backoff overrides * @returns Verified channel facts for settlement */ export async function fetchAndVerifyOpenChannel( - signer: UptoFacilitatorSigner, - network: string, + rpc: ChannelRpc, channelId: string, expected: ExpectedOpenChannel, policy: ChannelReadPolicy = {}, ): Promise { const resolved = resolveChannelReadPolicy(policy); - const rpc = accountFetchRpc(signer, network); for (let attempt = 1; attempt <= resolved.maxAttempts; attempt++) { const account = await fetchMaybeChannel(rpc, address(channelId), { commitment: STATE_COMMITMENT, @@ -281,10 +247,10 @@ export function verifyOpenChannelAccount( channel: Channel, expected: ExpectedOpenChannel, ): VerifiedOpenChannel { - if (channel.discriminator !== CHANNEL_ACCOUNT_DISCRIMINATOR) { + if (channel.discriminator !== AccountDiscriminator.Channel) { throw new Error(`channel ${channelId} has an invalid account discriminator`); } - if (channel.status !== CHANNEL_STATUS_OPEN) { + if (channel.status !== ChannelStatus.Open) { throw new Error(`channel ${channelId} is not open`); } @@ -324,34 +290,6 @@ export function verifyOpenChannelAccount( }; } -/** - * Thrown by {@link broadcastOpen} when the open transaction broadcast - * successfully but `confirmTransaction`'s wait timed out (outcome still - * unknown — a definite onchain failure propagates as - * {@link TransactionOnchainFailureError} instead). Distinct from a sign/send - * failure (nothing reached the chain, safe to retry): this carries the - * broadcast `signature` so the caller can reconcile against it instead of - * re-broadcasting (a second open would hit the channel-already-open check - * even though the original open is, or will be, fine). - */ -export class ChannelOpenConfirmationError extends Error { - /** - * Create the error for an open broadcast whose confirmation failed. - * - * @param signature - The broadcast signature whose confirmation failed - * @param cause - The underlying confirmation error - */ - constructor( - readonly signature: string, - cause: unknown, - ) { - super( - `failed to confirm channel open ${signature}: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - this.name = "ChannelOpenConfirmationError"; - } -} - /** * Co-sign the fee-payer slot of a partially-signed open transaction, * broadcast it, and wait for confirmation. No-op skip is the caller's job @@ -359,20 +297,11 @@ export class ChannelOpenConfirmationError extends Error { * * Uses the wire-level FacilitatorSvmSigner methods (same path as exact). * - * On a confirmation failure the broadcast signature is still surfaced (via - * {@link ChannelOpenConfirmationError}), unlike a sign/send failure — so the - * caller can distinguish "never landed, safe to retry with a fresh - * broadcast" from "broadcast successfully but unconfirmed, must reconcile - * against this signature instead of re-broadcasting." - * * @param facilitator - Facilitator signer with wire sign/send/confirm * @param feePayer - Fee-payer address to co-sign with * @param network - CAIP-2 network identifier * @param openTransactionBase64 - The client-signed open transaction * @returns The broadcast signature - * @throws {ChannelOpenConfirmationError} If the transaction broadcast but confirmation timed out - * @throws {TransactionOnchainFailureError} If the transaction broadcast but failed onchain - * (terminal — the caller can safely retry with a fresh open) */ export async function broadcastOpen( facilitator: Pick< @@ -382,24 +311,62 @@ export async function broadcastOpen( feePayer: Address, network: string, openTransactionBase64: string, + onBroadcast?: (signature: string) => Promise, + onPrepared?: (signature: string, wire: string) => Promise, ): Promise { const wire = await facilitator.signTransaction(openTransactionBase64, feePayer, network); - const signature = await facilitator.sendTransaction(wire, network); + let signature = onPrepared + ? getSignatureFromTransaction(getTransactionDecoder().decode(getBase64Codec().encode(wire))) + : ""; + if (onPrepared) await onPrepared(signature, wire); + try { + const sent = await facilitator.sendTransaction(wire, network); + if (!onPrepared) signature = sent; + } catch (error) { + if (onPrepared ?? onBroadcast) { + throw new ChannelBroadcastConfirmationError(signature, error); + } + throw error; + } + // Report the signature before confirming, so a caller that persists it can + // reconcile even if this process dies mid-wait. + await onBroadcast?.(signature); try { await facilitator.confirmTransaction(signature, network); } catch (error) { - // A definite onchain rejection is terminal, unlike a confirmation - // timeout: propagate it as-is (rather than wrapping in - // ChannelOpenConfirmationError) so the caller's non-pending branch - // handles it — a fresh open is safe to retry. if (error instanceof TransactionOnchainFailureError) { throw error; } - throw new ChannelOpenConfirmationError(signature, error); + // The transaction is on the network; only its outcome is unknown. Carry + // the signature out so the caller can record it and reconcile later + // instead of broadcasting the same escrow a second time. + throw new ChannelBroadcastConfirmationError(signature, error); } return signature; } +/** + * Thrown when a client transaction was broadcast but its confirmation could + * not be observed. + * + * Not a failure: the transaction may still land. The caller must persist + * {@link signature} and reconcile against it rather than rebroadcast, because + * a second broadcast of a setup transaction would escrow twice. + */ +export class ChannelBroadcastConfirmationError extends Error { + /** + * @param signature - Signature of the transaction already on the network + * @param cause - The confirmation error that interrupted the wait + */ + constructor( + readonly signature: string, + readonly cause: unknown, + ) { + super(`broadcast ${signature} could not be confirmed: ${String(cause)}`); + this.name = "ChannelBroadcastConfirmationError"; + } +} + /** Channel fields needed to build settle+distribute for readiness simulation. */ export interface SettlementSimChannel { channelId: string; @@ -417,29 +384,70 @@ export interface SettlementSimChannel { * Simulate `open` + `settle_and_seal(has_voucher=0)` + `distribute` against live * state before broadcasting open, so settlement-account failures reject without * escrowing the deposit. Never broadcast — only the original open-only tx is. - * - * Rebuilds a facilitator-owned message: client non-compute-budget instructions - * kept verbatim, compute-unit limit raised to the per-tx max (client opens cap - * at 400_000; the composite can exceed that), payer attached as a noop signer, - * `sigVerify: false`. - * - * @param feePayer - The fee-payer / channel payee signer - * @param signer - Facilitator signer (simulate RPC) - * @param network - CAIP-2 network identifier - * @param args - Open transaction and challenge-bound channel terms - * @param args.openTransactionBase64 - Client-signed open transaction - * @param args.channel - Challenge-bound channel terms for settle/distribute */ export async function simulateOpenSettleDistribute( - feePayer: UptoSvmSigner, + feePayer: PaymentChannelSvmSigner, + rpc: ChannelRpc, + args: { + openTransactionBase64: string; + channel: SettlementSimChannel; + }, +): Promise; +export async function simulateOpenSettleDistribute( + feePayer: PaymentChannelSvmSigner, signer: Pick, network: string, args: { openTransactionBase64: string; channel: SettlementSimChannel; }, +): Promise; +export async function simulateOpenSettleDistribute( + feePayer: PaymentChannelSvmSigner, + rpcOrSigner: ChannelRpc | Pick, + argsOrNetwork: + | { + openTransactionBase64: string; + channel: SettlementSimChannel; + } + | string, + maybeArgs?: { + openTransactionBase64: string; + channel: SettlementSimChannel; + }, ): Promise { - const { channel, openTransactionBase64 } = args; + if (typeof argsOrNetwork === "string") { + const args = maybeArgs; + if (!args) { + throw new Error("simulateOpenSettleDistribute: missing simulation args"); + } + const instructions = await buildOpenSettleDistributeSimulationInstructions( + feePayer, + args.openTransactionBase64, + args.channel, + ); + await simulateInstructionsViaSigner( + feePayer, + rpcOrSigner as Pick, + argsOrNetwork, + instructions, + ); + return; + } + const instructions = await buildOpenSettleDistributeSimulationInstructions( + feePayer, + argsOrNetwork.openTransactionBase64, + argsOrNetwork.channel, + ); + await simulateInstructions(feePayer, rpcOrSigner as ChannelRpc, instructions); +} + +/** Instruction list for an open + settle + distribute readiness simulation. */ +async function buildOpenSettleDistributeSimulationInstructions( + feePayer: PaymentChannelSvmSigner, + openTransactionBase64: string, + channel: SettlementSimChannel, +): Promise { const tx = getTransactionDecoder().decode(getBase64Codec().encode(openTransactionBase64)); const compiled = getCompiledTransactionMessageDecoder().decode(tx.messageBytes); const decompiled = decompileTransactionMessage(compiled); @@ -459,8 +467,6 @@ export async function simulateOpenSettleDistribute( } } - // Kit rejects two distinct signer objects for one address; when payer == - // feePayer the real signer covers both roles. const payerSigner = channel.payer === feePayer.address ? feePayer : createNoopSigner(address(channel.payer)); const openWithPayer = nonComputeBudget.map(ix => @@ -482,15 +488,13 @@ export async function simulateOpenSettleDistribute( tokenProgram: channel.tokenProgram, }); - const instructions: Instruction[] = [ + return [ getSetComputeUnitLimitInstruction({ units: SIM_COMPUTE_UNIT_LIMIT }), ...(computeUnitPrice ? [computeUnitPrice] : []), ...openWithPayer, ...settle, distribute, ]; - - await simulateInstructions(feePayer, signer, network, instructions); } /** Options for {@link submitSettle}. */ @@ -503,39 +507,30 @@ export interface SubmitSettleOptions { */ computeUnitLimit?: number | undefined; /** - * `SetComputeUnitPrice` in microlamports per compute unit attached to the - * settlement transaction; `0` omits the instruction. Defaults to - * `DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS` (1). + * Called with the signature once the transaction is on the network and + * before its confirmation is awaited, so a caller can persist it and + * reconcile later rather than broadcast the same work twice. */ - computeUnitPriceMicroLamports?: number | undefined; + onBroadcast?: ((signature: string) => Promise) | undefined; + /** Await recovery storage before the signed bytes can reach the network. */ + onPrepared?: ((signature: string, wire: string) => Promise) | undefined; /** - * Prefetched blockhash (e.g. from a parallel read in claim settle). When - * omitted, {@link submitSettle} fetches one via the signer. + * Blockhash to pin the transaction to. Fetched through the caller's + * transport when omitted; supply it to overlap the fetch with other reads. */ latestBlockhash?: { blockhash: string; lastValidBlockHeight: bigint } | undefined; -} - -/** - * Thrown by {@link submitSettle} when explicit simulation fails. The transaction - * is never broadcast. - */ -export class SettlementSimulationError extends Error { /** - * Create the error for a settlement simulation failure. - * - * @param cause - Underlying simulation error + * `SetComputeUnitPrice` in microlamports per compute unit attached to the + * settlement transaction; `0` omits the instruction. Defaults to + * `DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS` (1). */ - constructor(cause: unknown) { - super(cause instanceof Error ? cause.message : String(cause)); - this.name = "SettlementSimulationError"; - } + computeUnitPriceMicroLamports?: number | undefined; } /** * Compile the settle+distribute instructions into a transaction signed by the - * fee payer, simulate it, broadcast via the facilitator signer, and confirm. - * Other signers, such as the channel payee on `settle_and_seal`, are carried - * by the instruction list. + * fee payer, broadcast it, and confirm. Other signers, such as the channel + * payee on `settle_and_seal`, are carried by the instruction list. * * The transaction is prefixed with a statically sized `SetComputeUnitLimit` * and an optional `SetComputeUnitPrice`. Static sizing keeps the time-critical @@ -543,22 +538,122 @@ export class SettlementSimulationError extends Error { * operator-overridable for deployments outside the documented assumptions. * * @param feePayer - The fee-payer signer - * @param signer - Facilitator signer (blockhash, simulate, send, confirm) - * @param network - CAIP-2 network identifier + * @param rpc - The RPC client * @param instructions - settle_and_seal (+ optional Ed25519 precompile) then distribute * @param options - Compute-budget options * @returns The broadcast signature */ export async function submitSettle( - feePayer: UptoSvmSigner, - signer: Pick< - UptoFacilitatorSigner, - "getLatestBlockhash" | "simulateTransaction" | "sendTransaction" | "confirmTransaction" - >, + feePayer: PaymentChannelSvmSigner, + rpc: ChannelRpc, + instructions: readonly ServerInstruction[], + options: SubmitSettleOptions = {}, +): Promise { + const { value: fetched } = await rpc + .getLatestBlockhash({ commitment: BLOCKHASH_COMMITMENT }) + .send(); + const wire = await buildChannelTransaction( + feePayer, + { blockhash: fetched.blockhash, lastValidBlockHeight: fetched.lastValidBlockHeight }, + instructions, + options, + ); + const signature = await rpc.sendTransaction(wire, { encoding: "base64" }).send(); + await confirmSignature(rpc, signature); + return signature; +} + +/** The subset of a facilitator signer a channel submission needs. */ +export type ChannelSubmitSigner = { + getLatestBlockhash?( + network: string, + ): Promise<{ blockhash: string; lastValidBlockHeight: bigint }>; + simulateTransaction(transaction: string, network: string): Promise; + sendTransaction(transaction: string, network: string): Promise; + confirmTransaction(signature: string, network: string): Promise; +}; + +/** + * Thrown when explicit simulation rejects a channel transaction, so the caller + * can report it as a settlement-simulation failure rather than a generic send + * error. The transaction was never broadcast. + */ +export class ChannelSimulationError extends Error { + constructor(readonly cause: unknown) { + super(`channel transaction simulation failed: ${String(cause)}`); + this.name = "ChannelSimulationError"; + } +} + +/** + * Sign, simulate, broadcast and confirm a channel transaction through the + * facilitator signer. + * + * Simulation is explicit rather than left to the node's preflight, for two + * reasons: a preflight rejection arrives as an opaque send error, and a batch + * packs several channels into one transaction, so a caller that cannot tell + * simulation from transport has no way to report which batch failed or why. + * The signer then sends with preflight skipped, so the node does not simulate + * the same bytes a second time. + * + * @param feePayer - Signs as the transaction fee payer + * @param signer - Facilitator signer used for blockhash, simulate, send, confirm + * @param network - CAIP-2 network to submit against + * @param instructions - Channel instructions to submit + * @param options - Compute budget and blockhash overrides + * @returns The confirmed transaction signature + */ +export async function submitChannelTransactionWithSigner( + feePayer: PaymentChannelSvmSigner, + signer: ChannelSubmitSigner, network: string, instructions: readonly ServerInstruction[], options: SubmitSettleOptions = {}, ): Promise { + if (typeof signer.getLatestBlockhash !== "function") { + throw new Error( + "submitChannelTransactionWithSigner requires getLatestBlockhash on the signer. " + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } + const latestBlockhash = options.latestBlockhash ?? (await signer.getLatestBlockhash(network)); + const wire = await buildChannelTransaction(feePayer, latestBlockhash, instructions, options); + try { + await signer.simulateTransaction(wire, network); + } catch (error) { + throw new ChannelSimulationError(error); + } + let signature = options.onPrepared + ? getSignatureFromTransaction(getTransactionDecoder().decode(getBase64Codec().encode(wire))) + : ("" as Signature); + if (options.onPrepared) await options.onPrepared(signature, wire); + try { + const sent = await signer.sendTransaction(wire, network); + if (!options.onPrepared) signature = sent as Signature; + } catch { + throw new SettlementConfirmationTimeoutError(signature); + } + await options.onBroadcast?.(signature); + try { + await signer.confirmTransaction(signature, network); + } catch (error) { + if ( + error instanceof SettlementConfirmationTimeoutError || + error instanceof TransactionOnchainFailureError + ) + throw error; + throw new SettlementConfirmationTimeoutError(signature); + } + return signature; +} + +/** Build and sign the wire transaction both submission paths broadcast. */ +async function buildChannelTransaction( + feePayer: PaymentChannelSvmSigner, + latestBlockhash: { blockhash: string; lastValidBlockHeight: bigint }, + instructions: readonly ServerInstruction[], + options: SubmitSettleOptions, +): Promise> { const computeUnitLimit = options.computeUnitLimit ?? DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT; const computeUnitPrice = options.computeUnitPriceMicroLamports ?? DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS; @@ -568,8 +663,6 @@ export async function submitSettle( ? [getSetComputeUnitPriceInstruction({ microLamports: computeUnitPrice })] : []), ]; - - const latestBlockhash = options.latestBlockhash ?? (await signer.getLatestBlockhash(network)); const message = pipe( createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayerSigner(feePayer, m), @@ -584,27 +677,15 @@ export async function submitSettle( m => appendTransactionMessageInstructions([...computeBudgetIxs, ...instructions], m), ); const signed = await signTransactionMessageWithSigners(message); - const wire = getBase64EncodedWireTransaction(signed); - try { - await signer.simulateTransaction(wire, network); - } catch (error) { - throw new SettlementSimulationError(error); - } - const signature = await signer.sendTransaction(wire, network); - try { - await signer.confirmTransaction(signature, network); - } catch (error) { - if (error instanceof TransactionOnchainFailureError) { - throw error; - } - throw new SettlementConfirmationTimeoutError(signature as Signature); - } - return signature as Signature; + return getBase64EncodedWireTransaction(signed); } /** - * Thrown by {@link submitSettle} when confirmation polling times out. Distinct - * from an onchain rejection: the transaction's fate is unknown, not failed. + * Thrown by {@link confirmSignature} when the polling budget elapses before + * the transaction reaches `confirmed`. Distinct from an onchain rejection: + * the transaction's fate is unknown, not failed — it may still land. Callers + * must not treat this the same as a definite failure (e.g. must not assume + * it is safe to retry the same settlement). */ export class SettlementConfirmationTimeoutError extends Error { /** @@ -618,6 +699,43 @@ export class SettlementConfirmationTimeoutError extends Error { } } +/** + * Poll `getSignatureStatuses` until the signature reaches at least 'confirmed'. + * + * @param rpc - The RPC client + * @param signature - The transaction signature + * @param timeoutMs - Total time budget (default 30s) + * @throws {SettlementConfirmationTimeoutError} If the timeout elapses with the outcome still unknown + * @throws If the transaction failed onchain + */ +export async function confirmSignature( + rpc: ChannelRpc, + signature: Signature, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const { value } = await rpc.getSignatureStatuses([signature]).send(); + const status = value[0]; + if (status) { + if (status.err) { + const errorStr = JSON.stringify(status.err, (_, v) => + typeof v === "bigint" ? v.toString() : v, + ); + throw new Error(`tx ${signature} failed onchain: ${errorStr}`); + } + const level = status.confirmationStatus; + if (level === undefined || level === null || level === "confirmed" || level === "finalized") { + return; + } + } + if (Date.now() >= deadline) { + throw new SettlementConfirmationTimeoutError(signature); + } + await new Promise(resolve => setTimeout(resolve, 1_000)); + } +} + /** * Assert one decoded channel address matches its challenge-bound value. * @@ -659,12 +777,12 @@ export function getChannelDistributionHash(splits: readonly ChannelSplit[]): Uin * open composite may carry a noop payer) and `replaceRecentBlockhash: true`. * * @param feePayer - The fee-payer signer - * @param signer - Facilitator signer (simulate RPC) - * @param network - CAIP-2 network identifier + * @param rpc - The RPC client * @param instructions - Instructions to simulate */ -async function simulateInstructions( - feePayer: UptoSvmSigner, +/** Simulate through the facilitator signer's transport (upto / batch deposit path). */ +async function simulateInstructionsViaSigner( + feePayer: PaymentChannelSvmSigner, signer: Pick, network: string, instructions: readonly Instruction[], @@ -692,3 +810,42 @@ async function simulateInstructions( ); } } + +async function simulateInstructions( + feePayer: PaymentChannelSvmSigner, + rpc: ChannelRpc, + instructions: readonly Instruction[], +): Promise { + const { value: latestBlockhash } = await rpc + .getLatestBlockhash({ commitment: BLOCKHASH_COMMITMENT }) + .send(); + const message = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(feePayer, m), + m => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: latestBlockhash.blockhash as Blockhash, + lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, + }, + m, + ), + m => appendTransactionMessageInstructions(instructions, m), + ); + const signed = await partiallySignTransactionMessageWithSigners(message); + const wire = getBase64EncodedWireTransaction(signed); + const result = await rpc + .simulateTransaction(wire, { + commitment: STATE_COMMITMENT, + encoding: "base64", + replaceRecentBlockhash: true, + sigVerify: false, + }) + .send(); + if (result.value.err) { + const errorStr = JSON.stringify(result.value.err, (_, v) => + typeof v === "bigint" ? v.toString() : v, + ); + throw new Error(`zero-charge settlement simulation failed: ${errorStr}`); + } +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/account-meta.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/account-meta.ts deleted file mode 100644 index e2d2025cd3..0000000000 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/account-meta.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Self-contained replacement for the `getAccountMetaFactory` helper from - * `@solana/program-client-core`, so the vendored Payment Channels client - * needs no dependency beyond `@solana/kit`. Behaviour matches the upstream - * helper: a null account resolves to the program id (READONLY), a signer - * value upgrades the role to its `*_SIGNER` variant and is attached as - * `signer`, everything else maps writable/readonly by `isWritable`. - */ - -import { - AccountRole, - type Address, - isTransactionSigner, - type TransactionSigner, - upgradeRoleToSigner, -} from "@solana/kit"; - -/** A resolved instruction account: an address or signer value plus its writability. */ -export type ResolvedInstructionAccount = { - isWritable: boolean; - value: Address | TransactionSigner | null; -}; - -/** - * - * @param value - */ -function isSigner(value: Address | TransactionSigner): value is TransactionSigner { - return ( - typeof value === "object" && - value !== null && - "address" in value && - typeof (value as { address: unknown }).address === "string" && - isTransactionSigner(value as TransactionSigner) - ); -} - -/** - * Build the account-meta resolver used by the generated instruction builders. - * - * @param programAddress - The program id used to fill omitted optional accounts - * @param optionalAccountStrategy - "programId" fills null accounts with the program id; "omitted" drops them - * @returns A function mapping a resolved account to its `AccountMeta` - */ -export function getAccountMetaFactory( - programAddress: Address, - optionalAccountStrategy: "programId" | "omitted", -) { - return (_inputName: string, account: ResolvedInstructionAccount) => { - if (!account.value) { - if (optionalAccountStrategy === "omitted") return undefined; - return Object.freeze({ address: programAddress, role: AccountRole.READONLY }); - } - const writableRole = account.isWritable ? AccountRole.WRITABLE : AccountRole.READONLY; - const signer = isSigner(account.value); - const address = - typeof account.value === "object" && "address" in account.value - ? account.value.address - : account.value; - return Object.freeze({ - address, - role: signer ? upgradeRoleToSigner(writableRole) : writableRole, - ...(signer ? { signer: account.value } : {}), - }); - }; -} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/channel.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/channel.ts index 7bc5620b68..fef48e6950 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/channel.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/channel.ts @@ -43,7 +43,7 @@ import { getSettlementWatermarksEncoder, type SettlementWatermarks, type SettlementWatermarksArgs, -} from "../types/index.js"; +} from "../types"; export type Channel = { discriminator: number; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/index.ts new file mode 100644 index 0000000000..4e1b5142e5 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/accounts/index.ts @@ -0,0 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from "./channel"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/index.ts new file mode 100644 index 0000000000..254b137a1b --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/index.ts @@ -0,0 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +export * from "./paymentChannels"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/paymentChannels.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/paymentChannels.ts new file mode 100644 index 0000000000..ca00007cc4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/errors/paymentChannels.ts @@ -0,0 +1,243 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + isProgramError, + type Address, + type SOLANA_ERROR__INSTRUCTION_ERROR__CUSTOM, + type SolanaError, +} from "@solana/kit"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const PAYMENT_CHANNELS_ERROR__NOT_IMPLEMENTED = 0x0; // 0 +export const PAYMENT_CHANNELS_ERROR__MISSING_REQUIRED_SIGNATURE = 0x1; // 1 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_STATUS = 0x2; // 2 +export const PAYMENT_CHANNELS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR = 0x3; // 3 +export const PAYMENT_CHANNELS_ERROR__UNSUPPORTED_CHANNEL_VERSION = 0x4; // 4 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYER = 0x5; // 5 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYEE = 0x6; // 6 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_MINT = 0x7; // 7 +export const PAYMENT_CHANNELS_ERROR__INVALID_EVENT_AUTHORITY = 0x8; // 8 +export const PAYMENT_CHANNELS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS = 0x9; // 9 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_RENT_PAYER = 0xa; // 10 +export const PAYMENT_CHANNELS_ERROR__CHANNEL_ACCOUNT_MISMATCH = 0x32; // 50 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_ACCOUNT = 0x33; // 51 +export const PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_EXTENSIONS = 0x34; // 52 +export const PAYMENT_CHANNELS_ERROR__MINT_ACCOUNT_MISMATCH = 0x35; // 53 +export const PAYMENT_CHANNELS_ERROR__INVALID_MINT_TOKEN_PROGRAM = 0x36; // 54 +export const PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_ACCOUNT_DATA = 0x37; // 55 +export const PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_EXTENSIONS = 0x38; // 56 +export const PAYMENT_CHANNELS_ERROR__PAYER_ACCOUNT_MISMATCH = 0x39; // 57 +export const PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_ACCOUNT = 0x3a; // 58 +export const PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_EXTENSIONS = 0x3b; // 59 +export const PAYMENT_CHANNELS_ERROR__PAYEE_ACCOUNT_MISMATCH = 0x3c; // 60 +export const PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_ACCOUNT = 0x3d; // 61 +export const PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_EXTENSIONS = 0x3e; // 62 +export const PAYMENT_CHANNELS_ERROR__DEPOSIT_MUST_BE_NON_ZERO = 0xc8; // 200 +export const PAYMENT_CHANNELS_ERROR__GRACE_PERIOD_MUST_BE_NON_ZERO = 0xc9; // 201 +export const PAYMENT_CHANNELS_ERROR__MISSING_ED25519_VERIFICATION = 0xe6; // 230 +export const PAYMENT_CHANNELS_ERROR__MALFORMED_ED25519_INSTRUCTION = 0xe7; // 231 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_CHANNEL_MISMATCH = 0xe8; // 232 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_EXPIRED = 0xe9; // 233 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_WATERMARK_NOT_MONOTONIC = 0xea; // 234 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_OVER_DEPOSIT = 0xeb; // 235 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_MESSAGE_MISMATCH = 0xec; // 236 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_SIGNER_MISMATCH = 0xed; // 237 +export const PAYMENT_CHANNELS_ERROR__VOUCHER_BAD_MAGIC = 0xee; // 238 +export const PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_COUNT = 0x104; // 260 +export const PAYMENT_CHANNELS_ERROR__INVALID_SPLIT_CONFIG = 0x105; // 261 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PARTS_OVERFLOW = 0x106; // 262 +export const PAYMENT_CHANNELS_ERROR__DUPLICATE_RECIPIENT = 0x107; // 263 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTION_AMOUNT_OVERFLOW = 0x108; // 264 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PREIMAGE_LENGTH_OVERFLOW = 0x109; // 265 +export const PAYMENT_CHANNELS_ERROR__CHANNEL_ADDRESS_MISMATCH = 0x7d0; // 2000 +export const PAYMENT_CHANNELS_ERROR__PAYER_PAYEE_MUST_DIFFER = 0x7d1; // 2001 +export const PAYMENT_CHANNELS_ERROR__INVALID_AUTHORIZED_SIGNER = 0x7d2; // 2002 +export const PAYMENT_CHANNELS_ERROR__OPEN_SLOT_OUT_OF_WINDOW = 0x7d3; // 2003 +export const PAYMENT_CHANNELS_ERROR__TOP_UP_DEPOSIT_OVERFLOW = 0x834; // 2100 +export const PAYMENT_CHANNELS_ERROR__SEAL_DEADLINE_OVERFLOW = 0x898; // 2200 +export const PAYMENT_CHANNELS_ERROR__SEAL_GRACE_PERIOD_NOT_ELAPSED = 0x899; // 2201 +export const PAYMENT_CHANNELS_ERROR__PAYER_ALREADY_WITHDRAWN = 0x8fc; // 2300 +export const PAYMENT_CHANNELS_ERROR__REFUND_CALCULATION_OVERFLOW = 0x8fd; // 2301 +export const PAYMENT_CHANNELS_ERROR__CHANNEL_NOT_DISTRIBUTABLE = 0x960; // 2400 +export const PAYMENT_CHANNELS_ERROR__TREASURY_ACCOUNT_MISMATCH = 0x961; // 2401 +export const PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_ACCOUNT = 0x962; // 2402 +export const PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_EXTENSIONS = 0x963; // 2403 +export const PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_MISMATCH = 0x964; // 2404 +export const PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_ACCOUNT = 0x965; // 2405 +export const PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_EXTENSIONS = 0x966; // 2406 +export const PAYMENT_CHANNELS_ERROR__INVALID_DISTRIBUTION_HASH = 0x967; // 2407 +export const PAYMENT_CHANNELS_ERROR__NOTHING_TO_DISTRIBUTE = 0x968; // 2408 +export const PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_COUNT_MISMATCH = 0x969; // 2409 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTE_POOL_OVERFLOW = 0x96a; // 2410 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTE_BALANCE_CALCULATION_OVERFLOW = 0x96b; // 2411 +export const PAYMENT_CHANNELS_ERROR__RENT_PAYER_BALANCE_OVERFLOW = 0x96c; // 2412 +export const PAYMENT_CHANNELS_ERROR__DISTRIBUTE_TRANSFER_QUEUE_OVERFLOW = 0x96d; // 2413 +export const PAYMENT_CHANNELS_ERROR__CHANNEL_CLOSE_TOO_EARLY = 0x96e; // 2414 + +export type PaymentChannelsError = + | typeof PAYMENT_CHANNELS_ERROR__CHANNEL_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__CHANNEL_ADDRESS_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__CHANNEL_CLOSE_TOO_EARLY + | typeof PAYMENT_CHANNELS_ERROR__CHANNEL_NOT_DISTRIBUTABLE + | typeof PAYMENT_CHANNELS_ERROR__DEPOSIT_MUST_BE_NON_ZERO + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTE_BALANCE_CALCULATION_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTE_POOL_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTE_TRANSFER_QUEUE_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTION_AMOUNT_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PARTS_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PREIMAGE_LENGTH_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__DUPLICATE_RECIPIENT + | typeof PAYMENT_CHANNELS_ERROR__GRACE_PERIOD_MUST_BE_NON_ZERO + | typeof PAYMENT_CHANNELS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR + | typeof PAYMENT_CHANNELS_ERROR__INVALID_AUTHORIZED_SIGNER + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_MINT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYEE + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYER + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_RENT_PAYER + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_STATUS + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_ACCOUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__INVALID_DISTRIBUTION_HASH + | typeof PAYMENT_CHANNELS_ERROR__INVALID_EVENT_AUTHORITY + | typeof PAYMENT_CHANNELS_ERROR__INVALID_MINT_TOKEN_PROGRAM + | typeof PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_ACCOUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_ACCOUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_COUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_ACCOUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__INVALID_SPLIT_CONFIG + | typeof PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_ACCOUNT + | typeof PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__MALFORMED_ED25519_INSTRUCTION + | typeof PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_ACCOUNT_DATA + | typeof PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_EXTENSIONS + | typeof PAYMENT_CHANNELS_ERROR__MINT_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__MISSING_ED25519_VERIFICATION + | typeof PAYMENT_CHANNELS_ERROR__MISSING_REQUIRED_SIGNATURE + | typeof PAYMENT_CHANNELS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS + | typeof PAYMENT_CHANNELS_ERROR__NOTHING_TO_DISTRIBUTE + | typeof PAYMENT_CHANNELS_ERROR__NOT_IMPLEMENTED + | typeof PAYMENT_CHANNELS_ERROR__OPEN_SLOT_OUT_OF_WINDOW + | typeof PAYMENT_CHANNELS_ERROR__PAYEE_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__PAYER_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__PAYER_ALREADY_WITHDRAWN + | typeof PAYMENT_CHANNELS_ERROR__PAYER_PAYEE_MUST_DIFFER + | typeof PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_COUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__REFUND_CALCULATION_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__RENT_PAYER_BALANCE_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__SEAL_DEADLINE_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__SEAL_GRACE_PERIOD_NOT_ELAPSED + | typeof PAYMENT_CHANNELS_ERROR__TOP_UP_DEPOSIT_OVERFLOW + | typeof PAYMENT_CHANNELS_ERROR__TREASURY_ACCOUNT_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__UNSUPPORTED_CHANNEL_VERSION + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_BAD_MAGIC + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_CHANNEL_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_EXPIRED + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_MESSAGE_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_OVER_DEPOSIT + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_SIGNER_MISMATCH + | typeof PAYMENT_CHANNELS_ERROR__VOUCHER_WATERMARK_NOT_MONOTONIC; + +let paymentChannelsErrorMessages: Record | undefined; +if (process.env["NODE_ENV"] !== "production") { + paymentChannelsErrorMessages = { + [PAYMENT_CHANNELS_ERROR__CHANNEL_ACCOUNT_MISMATCH]: `Channel account does not match derived PDA`, + [PAYMENT_CHANNELS_ERROR__CHANNEL_ADDRESS_MISMATCH]: `Derived channel account address does not match the user provided address`, + [PAYMENT_CHANNELS_ERROR__CHANNEL_CLOSE_TOO_EARLY]: `Channel cannot be fully closed until clock.slot > open_slot + OPEN_SLOT_WINDOW`, + [PAYMENT_CHANNELS_ERROR__CHANNEL_NOT_DISTRIBUTABLE]: `Channel is not in OPEN or SEALED`, + [PAYMENT_CHANNELS_ERROR__DEPOSIT_MUST_BE_NON_ZERO]: `Deposit must be non-zero`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTE_BALANCE_CALCULATION_OVERFLOW]: `Channel rent rebalance calculation underflow`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTE_POOL_OVERFLOW]: `Distribution pool calculation underflow`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTE_TRANSFER_QUEUE_OVERFLOW]: `Transfer queue capacity exceeded`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTION_AMOUNT_OVERFLOW]: `num_recipients outside [0, 32]`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PARTS_OVERFLOW]: `num_recipients outside [0, 32]`, + [PAYMENT_CHANNELS_ERROR__DISTRIBUTION_PREIMAGE_LENGTH_OVERFLOW]: `Distribution preimage length calculation overflow`, + [PAYMENT_CHANNELS_ERROR__DUPLICATE_RECIPIENT]: `Distribution plan contains a duplicate recipient address`, + [PAYMENT_CHANNELS_ERROR__GRACE_PERIOD_MUST_BE_NON_ZERO]: `Grace period must be non-zero`, + [PAYMENT_CHANNELS_ERROR__INVALID_ACCOUNT_DISCRIMINATOR]: `Invalid account discriminator`, + [PAYMENT_CHANNELS_ERROR__INVALID_AUTHORIZED_SIGNER]: `authorized_signer must be a valid Ed25519 public key`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_MINT]: `Account does not match channel mint`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYEE]: `Account does not match channel payee`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_PAYER]: `Account does not match channel payer`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_RENT_PAYER]: `Account does not match channel rent_payer`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_STATUS]: `Invalid channel status`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_ACCOUNT]: `Channel token account is not ATA(channel, mint, token_program)`, + [PAYMENT_CHANNELS_ERROR__INVALID_CHANNEL_TOKEN_EXTENSIONS]: `Channel token account has invalid extensions`, + [PAYMENT_CHANNELS_ERROR__INVALID_DISTRIBUTION_HASH]: `Distribution hash mismatch`, + [PAYMENT_CHANNELS_ERROR__INVALID_EVENT_AUTHORITY]: `Invalid event authority`, + [PAYMENT_CHANNELS_ERROR__INVALID_MINT_TOKEN_PROGRAM]: `Token program must be SPL Token or Token-2022`, + [PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_ACCOUNT]: `Payee token account is invalid`, + [PAYMENT_CHANNELS_ERROR__INVALID_PAYEE_TOKEN_EXTENSIONS]: `Payee token account has invalid extensions`, + [PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_ACCOUNT]: `Payer token account is invalid`, + [PAYMENT_CHANNELS_ERROR__INVALID_PAYER_TOKEN_EXTENSIONS]: `Payer token account has invalid extensions`, + [PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_COUNT]: `num_recipients outside [0, 32]`, + [PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_ACCOUNT]: `Recipient token account is invalid`, + [PAYMENT_CHANNELS_ERROR__INVALID_RECIPIENT_TOKEN_EXTENSIONS]: `Recipient token account has invalid extensions`, + [PAYMENT_CHANNELS_ERROR__INVALID_SPLIT_CONFIG]: `Each shareBps must be non-zero and Σbps must be at most 10_000`, + [PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_ACCOUNT]: `Treasury token account is invalid`, + [PAYMENT_CHANNELS_ERROR__INVALID_TREASURY_TOKEN_EXTENSIONS]: `Treasury token account has invalid extensions`, + [PAYMENT_CHANNELS_ERROR__MALFORMED_ED25519_INSTRUCTION]: `Malformed Ed25519 precompile instruction`, + [PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_ACCOUNT_DATA]: `Token account or mint TLV trailer is malformed`, + [PAYMENT_CHANNELS_ERROR__MALFORMED_MINT_TOKEN_EXTENSIONS]: `Token account or mint TLV trailer is malformed`, + [PAYMENT_CHANNELS_ERROR__MINT_ACCOUNT_MISMATCH]: `Mint account does not match channel.mint`, + [PAYMENT_CHANNELS_ERROR__MISSING_ED25519_VERIFICATION]: `Missing Ed25519 precompile ix at current-1`, + [PAYMENT_CHANNELS_ERROR__MISSING_REQUIRED_SIGNATURE]: `A signature was required but not found`, + [PAYMENT_CHANNELS_ERROR__NOT_ENOUGH_ACCOUNT_KEYS]: `Not enough accounts were provided`, + [PAYMENT_CHANNELS_ERROR__NOTHING_TO_DISTRIBUTE]: `No newly settled funds to distribute`, + [PAYMENT_CHANNELS_ERROR__NOT_IMPLEMENTED]: `Not implemented`, + [PAYMENT_CHANNELS_ERROR__OPEN_SLOT_OUT_OF_WINDOW]: `open_slot is in the future or older than the allowed slot window`, + [PAYMENT_CHANNELS_ERROR__PAYEE_ACCOUNT_MISMATCH]: `Payee token account is not ATA(payee, token_program, mint)`, + [PAYMENT_CHANNELS_ERROR__PAYER_ACCOUNT_MISMATCH]: `Payer token account is not ATA(payer, token_program, mint)`, + [PAYMENT_CHANNELS_ERROR__PAYER_ALREADY_WITHDRAWN]: `Payer refund has already been claimed`, + [PAYMENT_CHANNELS_ERROR__PAYER_PAYEE_MUST_DIFFER]: `Payer and payee must be different accounts`, + [PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_COUNT_MISMATCH]: `Recipient ATA tail length does not match the committed plan's entry count`, + [PAYMENT_CHANNELS_ERROR__RECIPIENT_ACCOUNT_MISMATCH]: `Recipient token account is not ATA(recipient, token_program, mint)`, + [PAYMENT_CHANNELS_ERROR__REFUND_CALCULATION_OVERFLOW]: `Payer refund amount calculation underflow`, + [PAYMENT_CHANNELS_ERROR__RENT_PAYER_BALANCE_OVERFLOW]: `Rent payer lamports overflow on channel deallocation`, + [PAYMENT_CHANNELS_ERROR__SEAL_DEADLINE_OVERFLOW]: `Deadline overflow on grace period`, + [PAYMENT_CHANNELS_ERROR__SEAL_GRACE_PERIOD_NOT_ELAPSED]: `Grace period has not elapsed yet`, + [PAYMENT_CHANNELS_ERROR__TOP_UP_DEPOSIT_OVERFLOW]: `Deposit must be non-zero`, + [PAYMENT_CHANNELS_ERROR__TREASURY_ACCOUNT_MISMATCH]: `Treasury token account is not ATA(TREASURY_OWNER, mint, token_program)`, + [PAYMENT_CHANNELS_ERROR__UNSUPPORTED_CHANNEL_VERSION]: `Unsupported channel version`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_BAD_MAGIC]: `Voucher payload magic prefix is invalid`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_CHANNEL_MISMATCH]: `Voucher channel_id does not match channel PDA`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_EXPIRED]: `Voucher expired`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_MESSAGE_MISMATCH]: `Reserved (formerly: Ed25519 message does not match Borsh voucher payload)`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_OVER_DEPOSIT]: `Voucher cumulative_amount exceeds channel deposit`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_SIGNER_MISMATCH]: `Voucher signer does not match channel authorized_signer`, + [PAYMENT_CHANNELS_ERROR__VOUCHER_WATERMARK_NOT_MONOTONIC]: `Voucher watermark not strictly monotonic`, + }; +} + +export function getPaymentChannelsErrorMessage(code: PaymentChannelsError): string { + if (process.env["NODE_ENV"] !== "production") { + return (paymentChannelsErrorMessages as Record)[code]; + } + + return "Error message not available in production bundles."; +} + +export function isPaymentChannelsError( + error: unknown, + transactionMessage: { + instructions: Record; + }, + code?: TProgramErrorCode, +): error is SolanaError & + Readonly<{ context: Readonly<{ code: TProgramErrorCode }> }> { + return isProgramError( + error, + transactionMessage, + PAYMENT_CHANNELS_PROGRAM_ADDRESS, + code, + ); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/index.ts index 894dbeb0e5..74d275206c 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/index.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/index.ts @@ -1,14 +1,14 @@ /** - * Vendored subset of the Codama-generated Payment Channels TypeScript client. + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. * - * Source: https://github.com/solana-foundation/payment-channel/tree/main/clients/typescript/src/generated - * - * Only the symbols needed by the `upto` payment-channel scheme are vendored - * (open / settleAndSeal / distribute). Do not edit by hand; refresh from - * upstream when the generated client is updated. + * @see https://github.com/codama-idl/codama */ -export * from "./instructions/index"; -export * from "./pdas/index"; -export * from "./programs/index"; -export * from "./types/index"; +export * from "./accounts"; +export * from "./errors"; +export * from "./instructions"; +export * from "./pdas"; +export * from "./programs"; +export * from "./types"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/distribute.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/distribute.ts index 7aab19e83a..64684bdfd3 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/distribute.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/distribute.ts @@ -1,67 +1,68 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/instructions/distribute.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. * - * Parsing / async helpers omitted; only the synchronous `getDistributeInstruction` - * builder is needed. The `@solana/program-client-core` import is replaced with - * the vendored `account-meta` helper. + * @see https://github.com/codama-idl/codama */ import { - type AccountMeta, AccountRole, + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, type Address, type Codec, - combineCodec, type Decoder, type Encoder, - getStructDecoder, - getStructEncoder, type Instruction, type InstructionWithAccounts, type InstructionWithData, type ReadonlyAccount, type ReadonlyUint8Array, - transformEncoder, type WritableAccount, } from "@solana/kit"; - -import { getAccountMetaFactory, type ResolvedInstructionAccount } from "../account-meta"; -import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs/paymentChannels"; -import { getU8Decoder, getU8Encoder } from "../safe-codecs"; import { - type DistributeArgs, - type DistributeArgsArgs, + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { findEventAuthorityPda } from "../pdas"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; +import { getDistributeArgsDecoder, getDistributeArgsEncoder, -} from "../types/distributeArgs"; + type DistributeArgs, + type DistributeArgsArgs, +} from "../types"; export const DISTRIBUTE_DISCRIMINATOR = 7; -/** - * - */ export function getDistributeDiscriminatorBytes(): ReadonlyUint8Array { return getU8Encoder().encode(DISTRIBUTE_DISCRIMINATOR); } export type DistributeInstruction< TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, - TAccountChannel extends AccountMeta | string = string, - TAccountPayer extends AccountMeta | string = string, - TAccountRentPayer extends AccountMeta | string = string, - TAccountChannelTokenAccount extends AccountMeta | string = string, - TAccountPayerTokenAccount extends AccountMeta | string = string, - TAccountPayeeTokenAccount extends AccountMeta | string = string, - TAccountTreasuryTokenAccount extends AccountMeta | string = string, - TAccountMint extends AccountMeta | string = string, - TAccountTokenProgram extends AccountMeta | string = string, - TAccountEventAuthority extends AccountMeta | string = string, + TAccountChannel extends string | AccountMeta = string, + TAccountPayer extends string | AccountMeta = string, + TAccountRentPayer extends string | AccountMeta = string, + TAccountChannelTokenAccount extends string | AccountMeta = string, + TAccountPayerTokenAccount extends string | AccountMeta = string, + TAccountPayeeTokenAccount extends string | AccountMeta = string, + TAccountTreasuryTokenAccount extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = string, TAccountSelfProgram extends - | AccountMeta - | string = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", + | string + | AccountMeta = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & + InstructionWithData & InstructionWithAccounts< [ TAccountChannel extends string ? WritableAccount : TAccountChannel, @@ -91,15 +92,17 @@ export type DistributeInstruction< : TAccountSelfProgram, ...TRemainingAccounts, ] - > & - InstructionWithData; + >; -export type DistributeInstructionData = { discriminator: number; distributeArgs: DistributeArgs }; -export type DistributeInstructionDataArgs = { distributeArgs: DistributeArgsArgs }; +export type DistributeInstructionData = { + discriminator: number; + distributeArgs: DistributeArgs; +}; + +export type DistributeInstructionDataArgs = { + distributeArgs: DistributeArgsArgs; +}; -/** - * - */ export function getDistributeInstructionDataEncoder(): Encoder { return transformEncoder( getStructEncoder([ @@ -110,9 +113,6 @@ export function getDistributeInstructionDataEncoder(): Encoder { return getStructDecoder([ ["discriminator", getU8Decoder()], @@ -120,9 +120,6 @@ export function getDistributeInstructionDataDecoder(): Decoder = { channel: Address; + payer: Address; + rentPayer: Address; channelTokenAccount: Address; - distributeArgs: DistributeInstructionDataArgs["distributeArgs"]; - eventAuthority: Address; - mint: Address; + payerTokenAccount: Address; payeeTokenAccount: Address; + treasuryTokenAccount: Address; + mint: Address; + tokenProgram: Address; + eventAuthority?: Address; + selfProgram?: Address; + distributeArgs: DistributeInstructionDataArgs["distributeArgs"]; + recipientTokenAccounts: Array
; +}; + +export async function getDistributeInstructionAsync< + TAccountChannel extends string, + TAccountPayer extends string, + TAccountRentPayer extends string, + TAccountChannelTokenAccount extends string, + TAccountPayerTokenAccount extends string, + TAccountPayeeTokenAccount extends string, + TAccountTreasuryTokenAccount extends string, + TAccountMint extends string, + TAccountTokenProgram extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: DistributeAsyncInput< + TAccountChannel, + TAccountPayer, + TAccountRentPayer, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountPayeeTokenAccount, + TAccountTreasuryTokenAccount, + TAccountMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + DistributeInstruction< + TProgramAddress, + TAccountChannel, + TAccountPayer, + TAccountRentPayer, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountPayeeTokenAccount, + TAccountTreasuryTokenAccount, + TAccountMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + channel: { value: input.channel ?? null, isWritable: true }, + payer: { value: input.payer ?? null, isWritable: true }, + rentPayer: { value: input.rentPayer ?? null, isWritable: true }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + payeeTokenAccount: { + value: input.payeeTokenAccount ?? null, + isWritable: true, + }, + treasuryTokenAccount: { + value: input.treasuryTokenAccount ?? null, + isWritable: true, + }, + mint: { value: input.mint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = await findEventAuthorityPda(); + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" as Address<"CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX">; + } + + // Remaining accounts. + const remainingAccounts: AccountMeta[] = args.recipientTokenAccounts.map(address => ({ + address, + role: AccountRole.WRITABLE, + })); + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("channel", accounts.channel), + getAccountMeta("payer", accounts.payer), + getAccountMeta("rentPayer", accounts.rentPayer), + getAccountMeta("channelTokenAccount", accounts.channelTokenAccount), + getAccountMeta("payerTokenAccount", accounts.payerTokenAccount), + getAccountMeta("payeeTokenAccount", accounts.payeeTokenAccount), + getAccountMeta("treasuryTokenAccount", accounts.treasuryTokenAccount), + getAccountMeta("mint", accounts.mint), + getAccountMeta("tokenProgram", accounts.tokenProgram), + getAccountMeta("eventAuthority", accounts.eventAuthority), + getAccountMeta("selfProgram", accounts.selfProgram), + ...remainingAccounts, + ], + data: getDistributeInstructionDataEncoder().encode(args as DistributeInstructionDataArgs), + programAddress, + } as DistributeInstruction< + TProgramAddress, + TAccountChannel, + TAccountPayer, + TAccountRentPayer, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountPayeeTokenAccount, + TAccountTreasuryTokenAccount, + TAccountMint, + TAccountTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type DistributeInput< + TAccountChannel extends string = string, + TAccountPayer extends string = string, + TAccountRentPayer extends string = string, + TAccountChannelTokenAccount extends string = string, + TAccountPayerTokenAccount extends string = string, + TAccountPayeeTokenAccount extends string = string, + TAccountTreasuryTokenAccount extends string = string, + TAccountMint extends string = string, + TAccountTokenProgram extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { + channel: Address; payer: Address; rentPayer: Address; + channelTokenAccount: Address; payerTokenAccount: Address; - recipientTokenAccounts: Array
; - selfProgram?: Address; - tokenProgram: Address; + payeeTokenAccount: Address; treasuryTokenAccount: Address; + mint: Address; + tokenProgram: Address; + eventAuthority: Address; + selfProgram?: Address; + distributeArgs: DistributeInstructionDataArgs["distributeArgs"]; + recipientTokenAccounts: Array
; }; -/** - * - * @param input - * @param config - * @param config.programAddress - */ export function getDistributeInstruction< TAccountChannel extends string, TAccountPayer extends string, @@ -206,35 +355,52 @@ export function getDistributeInstruction< TAccountEventAuthority, TAccountSelfProgram > { + // Program address. const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + // Original accounts. const originalAccounts = { - channel: { isWritable: true, value: input.channel ?? null }, - channelTokenAccount: { isWritable: true, value: input.channelTokenAccount ?? null }, - eventAuthority: { isWritable: false, value: input.eventAuthority ?? null }, - mint: { isWritable: false, value: input.mint ?? null }, - payeeTokenAccount: { isWritable: true, value: input.payeeTokenAccount ?? null }, - payer: { isWritable: true, value: input.payer ?? null }, - rentPayer: { isWritable: true, value: input.rentPayer ?? null }, - payerTokenAccount: { isWritable: true, value: input.payerTokenAccount ?? null }, - selfProgram: { isWritable: false, value: input.selfProgram ?? null }, - tokenProgram: { isWritable: false, value: input.tokenProgram ?? null }, - treasuryTokenAccount: { isWritable: true, value: input.treasuryTokenAccount ?? null }, + channel: { value: input.channel ?? null, isWritable: true }, + payer: { value: input.payer ?? null, isWritable: true }, + rentPayer: { value: input.rentPayer ?? null, isWritable: true }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + payeeTokenAccount: { + value: input.payeeTokenAccount ?? null, + isWritable: true, + }, + treasuryTokenAccount: { + value: input.treasuryTokenAccount ?? null, + isWritable: true, + }, + mint: { value: input.mint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, }; const accounts = originalAccounts as Record< keyof typeof originalAccounts, ResolvedInstructionAccount >; + // Original args. const args = { ...input }; + // Resolve default values. if (!accounts.selfProgram.value) { accounts.selfProgram.value = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" as Address<"CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX">; } - const remainingAccounts: AccountMeta[] = args.recipientTokenAccounts.map(recipientAddress => ({ - address: recipientAddress, + // Remaining accounts. + const remainingAccounts: AccountMeta[] = args.recipientTokenAccounts.map(address => ({ + address, role: AccountRole.WRITABLE, })); @@ -271,3 +437,60 @@ export function getDistributeInstruction< TAccountSelfProgram >); } + +export type ParsedDistributeInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + channel: TAccountMetas[0]; + payer: TAccountMetas[1]; + rentPayer: TAccountMetas[2]; + channelTokenAccount: TAccountMetas[3]; + payerTokenAccount: TAccountMetas[4]; + payeeTokenAccount: TAccountMetas[5]; + treasuryTokenAccount: TAccountMetas[6]; + mint: TAccountMetas[7]; + tokenProgram: TAccountMetas[8]; + eventAuthority: TAccountMetas[9]; + selfProgram: TAccountMetas[10]; + }; + data: DistributeInstructionData; +}; + +export function parseDistributeInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedDistributeInstruction { + if (instruction.accounts.length < 11) { + throw new Error(`insufficient account metas: expected 11, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + channel: getNextAccount(), + payer: getNextAccount(), + rentPayer: getNextAccount(), + channelTokenAccount: getNextAccount(), + payerTokenAccount: getNextAccount(), + payeeTokenAccount: getNextAccount(), + treasuryTokenAccount: getNextAccount(), + mint: getNextAccount(), + tokenProgram: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getDistributeInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/emitEvent.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/emitEvent.ts new file mode 100644 index 0000000000..7bf389d187 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/emitEvent.ts @@ -0,0 +1,179 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { findEventAuthorityPda } from "../pdas"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const EMIT_EVENT_DISCRIMINATOR = 228; + +export function getEmitEventDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(EMIT_EVENT_DISCRIMINATOR); +} + +export type EmitEventInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountEventAuthority extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountEventAuthority extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountEventAuthority, + ...TRemainingAccounts, + ] + >; + +export type EmitEventInstructionData = { discriminator: number }; + +export type EmitEventInstructionDataArgs = {}; + +export function getEmitEventInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: EMIT_EVENT_DISCRIMINATOR, + })); +} + +export function getEmitEventInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getEmitEventInstructionDataCodec(): FixedSizeCodec< + EmitEventInstructionDataArgs, + EmitEventInstructionData +> { + return combineCodec(getEmitEventInstructionDataEncoder(), getEmitEventInstructionDataDecoder()); +} + +export type EmitEventAsyncInput = { + eventAuthority?: TransactionSigner; +}; + +export async function getEmitEventInstructionAsync< + TAccountEventAuthority extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: EmitEventAsyncInput, + config?: { programAddress?: TProgramAddress }, +): Promise> { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + // Resolve default values. + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = await findEventAuthorityPda(); + } + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [getAccountMeta("eventAuthority", accounts.eventAuthority)], + data: getEmitEventInstructionDataEncoder().encode({}), + programAddress, + } as EmitEventInstruction); +} + +export type EmitEventInput = { + eventAuthority: TransactionSigner; +}; + +export function getEmitEventInstruction< + TAccountEventAuthority extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: EmitEventInput, + config?: { programAddress?: TProgramAddress }, +): EmitEventInstruction { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [getAccountMeta("eventAuthority", accounts.eventAuthority)], + data: getEmitEventInstructionDataEncoder().encode({}), + programAddress, + } as EmitEventInstruction); +} + +export type ParsedEmitEventInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + eventAuthority: TAccountMetas[0]; + }; + data: EmitEventInstructionData; +}; + +export function parseEmitEventInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedEmitEventInstruction { + if (instruction.accounts.length < 1) { + throw new Error(`insufficient account metas: expected 1, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { eventAuthority: getNextAccount() }, + data: getEmitEventInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/index.ts index d7c9f97c43..9e6feda6bb 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/index.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/index.ts @@ -1,3 +1,18 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + export * from "./distribute"; +export * from "./emitEvent"; export * from "./open"; +export * from "./reclaim"; +export * from "./requestClose"; +export * from "./seal"; +export * from "./settle"; export * from "./settleAndSeal"; +export * from "./topUp"; +export * from "./withdrawPayer"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/open.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/open.ts index e6d379d4bb..af83db5ea2 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/open.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/open.ts @@ -1,80 +1,75 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/instructions/open.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. * - * Parsing / async helpers from the upstream file are intentionally omitted; - * only the synchronous `getOpenInstruction` builder is needed. The - * `@solana/program-client-core` import is replaced with the vendored - * `account-meta` helper. + * @see https://github.com/codama-idl/codama */ import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, type AccountMeta, type AccountSignerMeta, type Address, type Codec, - combineCodec, type Decoder, type Encoder, - getStructDecoder, - getStructEncoder, type Instruction, type InstructionWithAccounts, type InstructionWithData, type ReadonlyAccount, type ReadonlyUint8Array, type TransactionSigner, - transformEncoder, type WritableAccount, type WritableSignerAccount, } from "@solana/kit"; - -import { getAccountMetaFactory, type ResolvedInstructionAccount } from "../account-meta"; -import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs/paymentChannels"; -import { getU8Decoder, getU8Encoder } from "../safe-codecs"; import { - getOpenArgsDecoder, - getOpenArgsEncoder, - type OpenArgs, - type OpenArgsArgs, -} from "../types/openArgs"; + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { findEventAuthorityPda } from "../pdas"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; +import { getOpenArgsDecoder, getOpenArgsEncoder, type OpenArgs, type OpenArgsArgs } from "../types"; export const OPEN_DISCRIMINATOR = 1; -/** - * - */ export function getOpenDiscriminatorBytes(): ReadonlyUint8Array { return getU8Encoder().encode(OPEN_DISCRIMINATOR); } export type OpenInstruction< TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, - TAccountPayer extends AccountMeta | string = string, - TAccountRentPayer extends AccountMeta | string = string, - TAccountPayee extends AccountMeta | string = string, - TAccountMint extends AccountMeta | string = string, - TAccountAuthorizedSigner extends AccountMeta | string = string, - TAccountChannel extends AccountMeta | string = string, - TAccountPayerTokenAccount extends AccountMeta | string = string, - TAccountChannelTokenAccount extends AccountMeta | string = string, - TAccountTokenProgram extends AccountMeta | string = string, - TAccountSystemProgram extends AccountMeta | string = "11111111111111111111111111111111", - TAccountRent extends AccountMeta | string = string, - TAccountAssociatedTokenProgram extends AccountMeta | string = string, - TAccountEventAuthority extends AccountMeta | string = string, + TAccountPayer extends string | AccountMeta = string, + TAccountRentPayer extends string | AccountMeta = string, + TAccountPayee extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountAuthorizedSigner extends string | AccountMeta = string, + TAccountChannel extends string | AccountMeta = string, + TAccountPayerTokenAccount extends string | AccountMeta = string, + TAccountChannelTokenAccount extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TAccountSystemProgram extends string | AccountMeta = "11111111111111111111111111111111", + TAccountRent extends string | AccountMeta = string, + TAccountAssociatedTokenProgram extends string | AccountMeta = string, + TAccountEventAuthority extends string | AccountMeta = string, TAccountSelfProgram extends - | AccountMeta - | string = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", + | string + | AccountMeta = "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & + InstructionWithData & InstructionWithAccounts< [ TAccountPayer extends string - ? AccountSignerMeta & WritableSignerAccount + ? WritableSignerAccount & AccountSignerMeta : TAccountPayer, TAccountRentPayer extends string - ? AccountSignerMeta & WritableSignerAccount + ? WritableSignerAccount & AccountSignerMeta : TAccountRentPayer, TAccountPayee extends string ? ReadonlyAccount : TAccountPayee, TAccountMint extends string ? ReadonlyAccount : TAccountMint, @@ -106,16 +101,12 @@ export type OpenInstruction< : TAccountSelfProgram, ...TRemainingAccounts, ] - > & - InstructionWithData; + >; export type OpenInstructionData = { discriminator: number; openArgs: OpenArgs }; export type OpenInstructionDataArgs = { openArgs: OpenArgsArgs }; -/** - * - */ export function getOpenInstructionDataEncoder(): Encoder { return transformEncoder( getStructEncoder([ @@ -126,9 +117,6 @@ export function getOpenInstructionDataEncoder(): Encoder { return getStructDecoder([ ["discriminator", getU8Decoder()], @@ -136,14 +124,11 @@ export function getOpenInstructionDataDecoder(): Decoder { ]); } -/** - * - */ export function getOpenInstructionDataCodec(): Codec { return combineCodec(getOpenInstructionDataEncoder(), getOpenInstructionDataDecoder()); } -export type OpenInput< +export type OpenAsyncInput< TAccountPayer extends string = string, TAccountRentPayer extends string = string, TAccountPayee extends string = string, @@ -159,29 +144,201 @@ export type OpenInput< TAccountEventAuthority extends string = string, TAccountSelfProgram extends string = string, > = { - associatedTokenProgram: Address; + payer: TransactionSigner; + rentPayer: TransactionSigner; + payee: Address; + mint: Address; authorizedSigner: Address; channel: Address; + payerTokenAccount: Address; channelTokenAccount: Address; - eventAuthority: Address; - mint: Address; + tokenProgram: Address; + systemProgram?: Address; + rent: Address; + associatedTokenProgram: Address; + eventAuthority?: Address; + selfProgram?: Address; openArgs: OpenInstructionDataArgs["openArgs"]; - payee: Address; +}; + +export async function getOpenInstructionAsync< + TAccountPayer extends string, + TAccountRentPayer extends string, + TAccountPayee extends string, + TAccountMint extends string, + TAccountAuthorizedSigner extends string, + TAccountChannel extends string, + TAccountPayerTokenAccount extends string, + TAccountChannelTokenAccount extends string, + TAccountTokenProgram extends string, + TAccountSystemProgram extends string, + TAccountRent extends string, + TAccountAssociatedTokenProgram extends string, + TAccountEventAuthority extends string, + TAccountSelfProgram extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: OpenAsyncInput< + TAccountPayer, + TAccountRentPayer, + TAccountPayee, + TAccountMint, + TAccountAuthorizedSigner, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountTokenProgram, + TAccountSystemProgram, + TAccountRent, + TAccountAssociatedTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >, + config?: { programAddress?: TProgramAddress }, +): Promise< + OpenInstruction< + TProgramAddress, + TAccountPayer, + TAccountRentPayer, + TAccountPayee, + TAccountMint, + TAccountAuthorizedSigner, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountTokenProgram, + TAccountSystemProgram, + TAccountRent, + TAccountAssociatedTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + > +> { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + payer: { value: input.payer ?? null, isWritable: true }, + rentPayer: { value: input.rentPayer ?? null, isWritable: true }, + payee: { value: input.payee ?? null, isWritable: false }, + mint: { value: input.mint ?? null, isWritable: false }, + authorizedSigner: { + value: input.authorizedSigner ?? null, + isWritable: false, + }, + channel: { value: input.channel ?? null, isWritable: true }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + rent: { value: input.rent ?? null, isWritable: false }, + associatedTokenProgram: { + value: input.associatedTokenProgram ?? null, + isWritable: false, + }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + // Original args. + const args = { ...input }; + + // Resolve default values. + if (!accounts.systemProgram.value) { + accounts.systemProgram.value = + "11111111111111111111111111111111" as Address<"11111111111111111111111111111111">; + } + if (!accounts.eventAuthority.value) { + accounts.eventAuthority.value = await findEventAuthorityPda(); + } + if (!accounts.selfProgram.value) { + accounts.selfProgram.value = + "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX" as Address<"CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX">; + } + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("payer", accounts.payer), + getAccountMeta("rentPayer", accounts.rentPayer), + getAccountMeta("payee", accounts.payee), + getAccountMeta("mint", accounts.mint), + getAccountMeta("authorizedSigner", accounts.authorizedSigner), + getAccountMeta("channel", accounts.channel), + getAccountMeta("payerTokenAccount", accounts.payerTokenAccount), + getAccountMeta("channelTokenAccount", accounts.channelTokenAccount), + getAccountMeta("tokenProgram", accounts.tokenProgram), + getAccountMeta("systemProgram", accounts.systemProgram), + getAccountMeta("rent", accounts.rent), + getAccountMeta("associatedTokenProgram", accounts.associatedTokenProgram), + getAccountMeta("eventAuthority", accounts.eventAuthority), + getAccountMeta("selfProgram", accounts.selfProgram), + ], + data: getOpenInstructionDataEncoder().encode(args as OpenInstructionDataArgs), + programAddress, + } as OpenInstruction< + TProgramAddress, + TAccountPayer, + TAccountRentPayer, + TAccountPayee, + TAccountMint, + TAccountAuthorizedSigner, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountTokenProgram, + TAccountSystemProgram, + TAccountRent, + TAccountAssociatedTokenProgram, + TAccountEventAuthority, + TAccountSelfProgram + >); +} + +export type OpenInput< + TAccountPayer extends string = string, + TAccountRentPayer extends string = string, + TAccountPayee extends string = string, + TAccountMint extends string = string, + TAccountAuthorizedSigner extends string = string, + TAccountChannel extends string = string, + TAccountPayerTokenAccount extends string = string, + TAccountChannelTokenAccount extends string = string, + TAccountTokenProgram extends string = string, + TAccountSystemProgram extends string = string, + TAccountRent extends string = string, + TAccountAssociatedTokenProgram extends string = string, + TAccountEventAuthority extends string = string, + TAccountSelfProgram extends string = string, +> = { payer: TransactionSigner; rentPayer: TransactionSigner; + payee: Address; + mint: Address; + authorizedSigner: Address; + channel: Address; payerTokenAccount: Address; + channelTokenAccount: Address; + tokenProgram: Address; + systemProgram?: Address; rent: Address; + associatedTokenProgram: Address; + eventAuthority: Address; selfProgram?: Address; - systemProgram?: Address; - tokenProgram: Address; + openArgs: OpenInstructionDataArgs["openArgs"]; }; -/** - * - * @param input - * @param config - * @param config.programAddress - */ export function getOpenInstruction< TAccountPayer extends string, TAccountRentPayer extends string, @@ -233,31 +390,47 @@ export function getOpenInstruction< TAccountEventAuthority, TAccountSelfProgram > { + // Program address. const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + // Original accounts. const originalAccounts = { - authorizedSigner: { isWritable: false, value: input.authorizedSigner ?? null }, - associatedTokenProgram: { isWritable: false, value: input.associatedTokenProgram ?? null }, - channel: { isWritable: true, value: input.channel ?? null }, - channelTokenAccount: { isWritable: true, value: input.channelTokenAccount ?? null }, - eventAuthority: { isWritable: false, value: input.eventAuthority ?? null }, - mint: { isWritable: false, value: input.mint ?? null }, - payee: { isWritable: false, value: input.payee ?? null }, - payer: { isWritable: true, value: input.payer ?? null }, - rentPayer: { isWritable: true, value: input.rentPayer ?? null }, - payerTokenAccount: { isWritable: true, value: input.payerTokenAccount ?? null }, - rent: { isWritable: false, value: input.rent ?? null }, - selfProgram: { isWritable: false, value: input.selfProgram ?? null }, - systemProgram: { isWritable: false, value: input.systemProgram ?? null }, - tokenProgram: { isWritable: false, value: input.tokenProgram ?? null }, + payer: { value: input.payer ?? null, isWritable: true }, + rentPayer: { value: input.rentPayer ?? null, isWritable: true }, + payee: { value: input.payee ?? null, isWritable: false }, + mint: { value: input.mint ?? null, isWritable: false }, + authorizedSigner: { + value: input.authorizedSigner ?? null, + isWritable: false, + }, + channel: { value: input.channel ?? null, isWritable: true }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + systemProgram: { value: input.systemProgram ?? null, isWritable: false }, + rent: { value: input.rent ?? null, isWritable: false }, + associatedTokenProgram: { + value: input.associatedTokenProgram ?? null, + isWritable: false, + }, + eventAuthority: { value: input.eventAuthority ?? null, isWritable: false }, + selfProgram: { value: input.selfProgram ?? null, isWritable: false }, }; const accounts = originalAccounts as Record< keyof typeof originalAccounts, ResolvedInstructionAccount >; + // Original args. const args = { ...input }; + // Resolve default values. if (!accounts.systemProgram.value) { accounts.systemProgram.value = "11111111111111111111111111111111" as Address<"11111111111111111111111111111111">; @@ -305,3 +478,66 @@ export function getOpenInstruction< TAccountSelfProgram >); } + +export type ParsedOpenInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + payer: TAccountMetas[0]; + rentPayer: TAccountMetas[1]; + payee: TAccountMetas[2]; + mint: TAccountMetas[3]; + authorizedSigner: TAccountMetas[4]; + channel: TAccountMetas[5]; + payerTokenAccount: TAccountMetas[6]; + channelTokenAccount: TAccountMetas[7]; + tokenProgram: TAccountMetas[8]; + systemProgram: TAccountMetas[9]; + rent: TAccountMetas[10]; + associatedTokenProgram: TAccountMetas[11]; + eventAuthority: TAccountMetas[12]; + selfProgram: TAccountMetas[13]; + }; + data: OpenInstructionData; +}; + +export function parseOpenInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedOpenInstruction { + if (instruction.accounts.length < 14) { + throw new Error(`insufficient account metas: expected 14, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + payer: getNextAccount(), + rentPayer: getNextAccount(), + payee: getNextAccount(), + mint: getNextAccount(), + authorizedSigner: getNextAccount(), + channel: getNextAccount(), + payerTokenAccount: getNextAccount(), + channelTokenAccount: getNextAccount(), + tokenProgram: getNextAccount(), + systemProgram: getNextAccount(), + rent: getNextAccount(), + associatedTokenProgram: getNextAccount(), + eventAuthority: getNextAccount(), + selfProgram: getNextAccount(), + }, + data: getOpenInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/reclaim.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/reclaim.ts new file mode 100644 index 0000000000..12e89babe2 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/reclaim.ts @@ -0,0 +1,150 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type WritableAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const RECLAIM_DISCRIMINATOR = 9; + +export function getReclaimDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(RECLAIM_DISCRIMINATOR); +} + +export type ReclaimInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountChannel extends string | AccountMeta = string, + TAccountRentPayer extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountChannel extends string ? WritableAccount : TAccountChannel, + TAccountRentPayer extends string ? WritableAccount : TAccountRentPayer, + ...TRemainingAccounts, + ] + >; + +export type ReclaimInstructionData = { discriminator: number }; + +export type ReclaimInstructionDataArgs = {}; + +export function getReclaimInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: RECLAIM_DISCRIMINATOR, + })); +} + +export function getReclaimInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getReclaimInstructionDataCodec(): FixedSizeCodec< + ReclaimInstructionDataArgs, + ReclaimInstructionData +> { + return combineCodec(getReclaimInstructionDataEncoder(), getReclaimInstructionDataDecoder()); +} + +export type ReclaimInput< + TAccountChannel extends string = string, + TAccountRentPayer extends string = string, +> = { + channel: Address; + rentPayer: Address; +}; + +export function getReclaimInstruction< + TAccountChannel extends string, + TAccountRentPayer extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: ReclaimInput, + config?: { programAddress?: TProgramAddress }, +): ReclaimInstruction { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + channel: { value: input.channel ?? null, isWritable: true }, + rentPayer: { value: input.rentPayer ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("channel", accounts.channel), + getAccountMeta("rentPayer", accounts.rentPayer), + ], + data: getReclaimInstructionDataEncoder().encode({}), + programAddress, + } as ReclaimInstruction); +} + +export type ParsedReclaimInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + channel: TAccountMetas[0]; + rentPayer: TAccountMetas[1]; + }; + data: ReclaimInstructionData; +}; + +export function parseReclaimInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedReclaimInstruction { + if (instruction.accounts.length < 2) { + throw new Error(`insufficient account metas: expected 2, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { channel: getNextAccount(), rentPayer: getNextAccount() }, + data: getReclaimInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/requestClose.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/requestClose.ts new file mode 100644 index 0000000000..46c91821ee --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/requestClose.ts @@ -0,0 +1,158 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const REQUEST_CLOSE_DISCRIMINATOR = 5; + +export function getRequestCloseDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(REQUEST_CLOSE_DISCRIMINATOR); +} + +export type RequestCloseInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountPayer extends string | AccountMeta = string, + TAccountChannel extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountPayer extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountPayer, + TAccountChannel extends string ? WritableAccount : TAccountChannel, + ...TRemainingAccounts, + ] + >; + +export type RequestCloseInstructionData = { discriminator: number }; + +export type RequestCloseInstructionDataArgs = {}; + +export function getRequestCloseInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: REQUEST_CLOSE_DISCRIMINATOR, + })); +} + +export function getRequestCloseInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getRequestCloseInstructionDataCodec(): FixedSizeCodec< + RequestCloseInstructionDataArgs, + RequestCloseInstructionData +> { + return combineCodec( + getRequestCloseInstructionDataEncoder(), + getRequestCloseInstructionDataDecoder(), + ); +} + +export type RequestCloseInput< + TAccountPayer extends string = string, + TAccountChannel extends string = string, +> = { + payer: TransactionSigner; + channel: Address; +}; + +export function getRequestCloseInstruction< + TAccountPayer extends string, + TAccountChannel extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: RequestCloseInput, + config?: { programAddress?: TProgramAddress }, +): RequestCloseInstruction { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + payer: { value: input.payer ?? null, isWritable: false }, + channel: { value: input.channel ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("payer", accounts.payer), + getAccountMeta("channel", accounts.channel), + ], + data: getRequestCloseInstructionDataEncoder().encode({}), + programAddress, + } as RequestCloseInstruction); +} + +export type ParsedRequestCloseInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + payer: TAccountMetas[0]; + channel: TAccountMetas[1]; + }; + data: RequestCloseInstructionData; +}; + +export function parseRequestCloseInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedRequestCloseInstruction { + if (instruction.accounts.length < 2) { + throw new Error(`insufficient account metas: expected 2, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { payer: getNextAccount(), channel: getNextAccount() }, + data: getRequestCloseInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/seal.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/seal.ts new file mode 100644 index 0000000000..00dc034399 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/seal.ts @@ -0,0 +1,138 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyUint8Array, + type WritableAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const SEAL_DISCRIMINATOR = 6; + +export function getSealDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(SEAL_DISCRIMINATOR); +} + +export type SealInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountChannel extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountChannel extends string ? WritableAccount : TAccountChannel, + ...TRemainingAccounts, + ] + >; + +export type SealInstructionData = { discriminator: number }; + +export type SealInstructionDataArgs = {}; + +export function getSealInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: SEAL_DISCRIMINATOR, + })); +} + +export function getSealInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getSealInstructionDataCodec(): FixedSizeCodec< + SealInstructionDataArgs, + SealInstructionData +> { + return combineCodec(getSealInstructionDataEncoder(), getSealInstructionDataDecoder()); +} + +export type SealInput = { + channel: Address; +}; + +export function getSealInstruction< + TAccountChannel extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: SealInput, + config?: { programAddress?: TProgramAddress }, +): SealInstruction { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + channel: { value: input.channel ?? null, isWritable: true }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [getAccountMeta("channel", accounts.channel)], + data: getSealInstructionDataEncoder().encode({}), + programAddress, + } as SealInstruction); +} + +export type ParsedSealInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + channel: TAccountMetas[0]; + }; + data: SealInstructionData; +}; + +export function parseSealInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedSealInstruction { + if (instruction.accounts.length < 1) { + throw new Error(`insufficient account metas: expected 1, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { channel: getNextAccount() }, + data: getSealInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settle.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settle.ts new file mode 100644 index 0000000000..c2fbf74ec6 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settle.ts @@ -0,0 +1,159 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type WritableAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const SETTLE_DISCRIMINATOR = 2; + +export function getSettleDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(SETTLE_DISCRIMINATOR); +} + +export type SettleInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountChannel extends string | AccountMeta = string, + TAccountInstructionsSysvar extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountChannel extends string ? WritableAccount : TAccountChannel, + TAccountInstructionsSysvar extends string + ? ReadonlyAccount + : TAccountInstructionsSysvar, + ...TRemainingAccounts, + ] + >; + +export type SettleInstructionData = { discriminator: number }; + +export type SettleInstructionDataArgs = {}; + +export function getSettleInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: SETTLE_DISCRIMINATOR, + })); +} + +export function getSettleInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getSettleInstructionDataCodec(): FixedSizeCodec< + SettleInstructionDataArgs, + SettleInstructionData +> { + return combineCodec(getSettleInstructionDataEncoder(), getSettleInstructionDataDecoder()); +} + +export type SettleInput< + TAccountChannel extends string = string, + TAccountInstructionsSysvar extends string = string, +> = { + channel: Address; + instructionsSysvar: Address; +}; + +export function getSettleInstruction< + TAccountChannel extends string, + TAccountInstructionsSysvar extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: SettleInput, + config?: { programAddress?: TProgramAddress }, +): SettleInstruction { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + channel: { value: input.channel ?? null, isWritable: true }, + instructionsSysvar: { + value: input.instructionsSysvar ?? null, + isWritable: false, + }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("channel", accounts.channel), + getAccountMeta("instructionsSysvar", accounts.instructionsSysvar), + ], + data: getSettleInstructionDataEncoder().encode({}), + programAddress, + } as SettleInstruction); +} + +export type ParsedSettleInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + channel: TAccountMetas[0]; + instructionsSysvar: TAccountMetas[1]; + }; + data: SettleInstructionData; +}; + +export function parseSettleInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedSettleInstruction { + if (instruction.accounts.length < 2) { + throw new Error(`insufficient account metas: expected 2, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + channel: getNextAccount(), + instructionsSysvar: getNextAccount(), + }, + data: getSettleInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settleAndSeal.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settleAndSeal.ts index 81d45ea644..e02685edf4 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settleAndSeal.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/settleAndSeal.ts @@ -1,22 +1,24 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/instructions/settleAndSeal.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. * - * Parsing helpers omitted; only the synchronous `getSettleAndSealInstruction` - * builder is needed. The `@solana/program-client-core` import is replaced with - * the vendored `account-meta` helper. + * @see https://github.com/codama-idl/codama */ import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, type AccountMeta, type AccountSignerMeta, type Address, - combineCodec, type FixedSizeCodec, type FixedSizeDecoder, type FixedSizeEncoder, - getStructDecoder, - getStructEncoder, type Instruction, type InstructionWithAccounts, type InstructionWithData, @@ -24,40 +26,38 @@ import { type ReadonlySignerAccount, type ReadonlyUint8Array, type TransactionSigner, - transformEncoder, type WritableAccount, } from "@solana/kit"; - -import { getAccountMetaFactory, type ResolvedInstructionAccount } from "../account-meta"; -import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs/paymentChannels"; -import { getU8Decoder, getU8Encoder } from "../safe-codecs"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; import { getSettleAndSealArgsDecoder, getSettleAndSealArgsEncoder, type SettleAndSealArgs, type SettleAndSealArgsArgs, -} from "../types/settleAndSealArgs"; +} from "../types"; export const SETTLE_AND_SEAL_DISCRIMINATOR = 4; -/** - * - */ export function getSettleAndSealDiscriminatorBytes(): ReadonlyUint8Array { return getU8Encoder().encode(SETTLE_AND_SEAL_DISCRIMINATOR); } export type SettleAndSealInstruction< TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, - TAccountPayee extends AccountMeta | string = string, - TAccountChannel extends AccountMeta | string = string, - TAccountInstructionsSysvar extends AccountMeta | string = string, + TAccountPayee extends string | AccountMeta = string, + TAccountChannel extends string | AccountMeta = string, + TAccountInstructionsSysvar extends string | AccountMeta = string, TRemainingAccounts extends readonly AccountMeta[] = [], > = Instruction & + InstructionWithData & InstructionWithAccounts< [ TAccountPayee extends string - ? AccountSignerMeta & ReadonlySignerAccount + ? ReadonlySignerAccount & AccountSignerMeta : TAccountPayee, TAccountChannel extends string ? WritableAccount : TAccountChannel, TAccountInstructionsSysvar extends string @@ -65,8 +65,7 @@ export type SettleAndSealInstruction< : TAccountInstructionsSysvar, ...TRemainingAccounts, ] - > & - InstructionWithData; + >; export type SettleAndSealInstructionData = { discriminator: number; @@ -77,9 +76,6 @@ export type SettleAndSealInstructionDataArgs = { settleAndSealArgs: SettleAndSealArgsArgs; }; -/** - * - */ export function getSettleAndSealInstructionDataEncoder(): FixedSizeEncoder { return transformEncoder( getStructEncoder([ @@ -90,9 +86,6 @@ export function getSettleAndSealInstructionDataEncoder(): FixedSizeEncoder { return getStructDecoder([ ["discriminator", getU8Decoder()], @@ -100,9 +93,6 @@ export function getSettleAndSealInstructionDataDecoder(): FixedSizeDecoder = { + payee: TransactionSigner; channel: Address; instructionsSysvar: Address; - payee: TransactionSigner; settleAndSealArgs: SettleAndSealInstructionDataArgs["settleAndSealArgs"]; }; -/** - * - * @param input - * @param config - * @param config.programAddress - */ export function getSettleAndSealInstruction< TAccountPayee extends string, TAccountChannel extends string, @@ -144,18 +128,24 @@ export function getSettleAndSealInstruction< TAccountChannel, TAccountInstructionsSysvar > { + // Program address. const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + // Original accounts. const originalAccounts = { - channel: { isWritable: true, value: input.channel ?? null }, - instructionsSysvar: { isWritable: false, value: input.instructionsSysvar ?? null }, - payee: { isWritable: false, value: input.payee ?? null }, + payee: { value: input.payee ?? null, isWritable: false }, + channel: { value: input.channel ?? null, isWritable: true }, + instructionsSysvar: { + value: input.instructionsSysvar ?? null, + isWritable: false, + }, }; const accounts = originalAccounts as Record< keyof typeof originalAccounts, ResolvedInstructionAccount >; + // Original args. const args = { ...input }; const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); @@ -174,3 +164,44 @@ export function getSettleAndSealInstruction< TAccountInstructionsSysvar >); } + +export type ParsedSettleAndSealInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + payee: TAccountMetas[0]; + channel: TAccountMetas[1]; + instructionsSysvar: TAccountMetas[2]; + }; + data: SettleAndSealInstructionData; +}; + +export function parseSettleAndSealInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedSettleAndSealInstruction { + if (instruction.accounts.length < 3) { + throw new Error(`insufficient account metas: expected 3, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + payee: getNextAccount(), + channel: getNextAccount(), + instructionsSysvar: getNextAccount(), + }, + data: getSettleAndSealInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/topUp.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/topUp.ts new file mode 100644 index 0000000000..1ed14e0702 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/topUp.ts @@ -0,0 +1,249 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, + type WritableSignerAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; +import { + getTopUpArgsDecoder, + getTopUpArgsEncoder, + type TopUpArgs, + type TopUpArgsArgs, +} from "../types"; + +export const TOP_UP_DISCRIMINATOR = 3; + +export function getTopUpDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(TOP_UP_DISCRIMINATOR); +} + +export type TopUpInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountPayer extends string | AccountMeta = string, + TAccountChannel extends string | AccountMeta = string, + TAccountPayerTokenAccount extends string | AccountMeta = string, + TAccountChannelTokenAccount extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountPayer extends string + ? WritableSignerAccount & AccountSignerMeta + : TAccountPayer, + TAccountChannel extends string ? WritableAccount : TAccountChannel, + TAccountPayerTokenAccount extends string + ? WritableAccount + : TAccountPayerTokenAccount, + TAccountChannelTokenAccount extends string + ? WritableAccount + : TAccountChannelTokenAccount, + TAccountMint extends string ? ReadonlyAccount : TAccountMint, + TAccountTokenProgram extends string + ? ReadonlyAccount + : TAccountTokenProgram, + ...TRemainingAccounts, + ] + >; + +export type TopUpInstructionData = { + discriminator: number; + topUpArgs: TopUpArgs; +}; + +export type TopUpInstructionDataArgs = { topUpArgs: TopUpArgsArgs }; + +export function getTopUpInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder( + getStructEncoder([ + ["discriminator", getU8Encoder()], + ["topUpArgs", getTopUpArgsEncoder()], + ]), + value => ({ ...value, discriminator: TOP_UP_DISCRIMINATOR }), + ); +} + +export function getTopUpInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ["discriminator", getU8Decoder()], + ["topUpArgs", getTopUpArgsDecoder()], + ]); +} + +export function getTopUpInstructionDataCodec(): FixedSizeCodec< + TopUpInstructionDataArgs, + TopUpInstructionData +> { + return combineCodec(getTopUpInstructionDataEncoder(), getTopUpInstructionDataDecoder()); +} + +export type TopUpInput< + TAccountPayer extends string = string, + TAccountChannel extends string = string, + TAccountPayerTokenAccount extends string = string, + TAccountChannelTokenAccount extends string = string, + TAccountMint extends string = string, + TAccountTokenProgram extends string = string, +> = { + payer: TransactionSigner; + channel: Address; + payerTokenAccount: Address; + channelTokenAccount: Address; + mint: Address; + tokenProgram: Address; + topUpArgs: TopUpInstructionDataArgs["topUpArgs"]; +}; + +export function getTopUpInstruction< + TAccountPayer extends string, + TAccountChannel extends string, + TAccountPayerTokenAccount extends string, + TAccountChannelTokenAccount extends string, + TAccountMint extends string, + TAccountTokenProgram extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: TopUpInput< + TAccountPayer, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountMint, + TAccountTokenProgram + >, + config?: { programAddress?: TProgramAddress }, +): TopUpInstruction< + TProgramAddress, + TAccountPayer, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountMint, + TAccountTokenProgram +> { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + payer: { value: input.payer ?? null, isWritable: true }, + channel: { value: input.channel ?? null, isWritable: true }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + mint: { value: input.mint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + // Original args. + const args = { ...input }; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("payer", accounts.payer), + getAccountMeta("channel", accounts.channel), + getAccountMeta("payerTokenAccount", accounts.payerTokenAccount), + getAccountMeta("channelTokenAccount", accounts.channelTokenAccount), + getAccountMeta("mint", accounts.mint), + getAccountMeta("tokenProgram", accounts.tokenProgram), + ], + data: getTopUpInstructionDataEncoder().encode(args as TopUpInstructionDataArgs), + programAddress, + } as TopUpInstruction< + TProgramAddress, + TAccountPayer, + TAccountChannel, + TAccountPayerTokenAccount, + TAccountChannelTokenAccount, + TAccountMint, + TAccountTokenProgram + >); +} + +export type ParsedTopUpInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + payer: TAccountMetas[0]; + channel: TAccountMetas[1]; + payerTokenAccount: TAccountMetas[2]; + channelTokenAccount: TAccountMetas[3]; + mint: TAccountMetas[4]; + tokenProgram: TAccountMetas[5]; + }; + data: TopUpInstructionData; +}; + +export function parseTopUpInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedTopUpInstruction { + if (instruction.accounts.length < 6) { + throw new Error(`insufficient account metas: expected 6, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + payer: getNextAccount(), + channel: getNextAccount(), + payerTokenAccount: getNextAccount(), + channelTokenAccount: getNextAccount(), + mint: getNextAccount(), + tokenProgram: getNextAccount(), + }, + data: getTopUpInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/withdrawPayer.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/withdrawPayer.ts new file mode 100644 index 0000000000..9bce4333c4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/instructions/withdrawPayer.ts @@ -0,0 +1,233 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, + transformEncoder, + type AccountMeta, + type AccountSignerMeta, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, + type Instruction, + type InstructionWithAccounts, + type InstructionWithData, + type ReadonlyAccount, + type ReadonlySignerAccount, + type ReadonlyUint8Array, + type TransactionSigner, + type WritableAccount, +} from "@solana/kit"; +import { + getAccountMetaFactory, + type ResolvedInstructionAccount, +} from "@solana/program-client-core"; +import { PAYMENT_CHANNELS_PROGRAM_ADDRESS } from "../programs"; + +export const WITHDRAW_PAYER_DISCRIMINATOR = 8; + +export function getWithdrawPayerDiscriminatorBytes(): ReadonlyUint8Array { + return getU8Encoder().encode(WITHDRAW_PAYER_DISCRIMINATOR); +} + +export type WithdrawPayerInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountPayer extends string | AccountMeta = string, + TAccountChannel extends string | AccountMeta = string, + TAccountChannelTokenAccount extends string | AccountMeta = string, + TAccountPayerTokenAccount extends string | AccountMeta = string, + TAccountMint extends string | AccountMeta = string, + TAccountTokenProgram extends string | AccountMeta = string, + TRemainingAccounts extends readonly AccountMeta[] = [], +> = Instruction & + InstructionWithData & + InstructionWithAccounts< + [ + TAccountPayer extends string + ? ReadonlySignerAccount & AccountSignerMeta + : TAccountPayer, + TAccountChannel extends string ? WritableAccount : TAccountChannel, + TAccountChannelTokenAccount extends string + ? WritableAccount + : TAccountChannelTokenAccount, + TAccountPayerTokenAccount extends string + ? WritableAccount + : TAccountPayerTokenAccount, + TAccountMint extends string ? ReadonlyAccount : TAccountMint, + TAccountTokenProgram extends string + ? ReadonlyAccount + : TAccountTokenProgram, + ...TRemainingAccounts, + ] + >; + +export type WithdrawPayerInstructionData = { discriminator: number }; + +export type WithdrawPayerInstructionDataArgs = {}; + +export function getWithdrawPayerInstructionDataEncoder(): FixedSizeEncoder { + return transformEncoder(getStructEncoder([["discriminator", getU8Encoder()]]), value => ({ + ...value, + discriminator: WITHDRAW_PAYER_DISCRIMINATOR, + })); +} + +export function getWithdrawPayerInstructionDataDecoder(): FixedSizeDecoder { + return getStructDecoder([["discriminator", getU8Decoder()]]); +} + +export function getWithdrawPayerInstructionDataCodec(): FixedSizeCodec< + WithdrawPayerInstructionDataArgs, + WithdrawPayerInstructionData +> { + return combineCodec( + getWithdrawPayerInstructionDataEncoder(), + getWithdrawPayerInstructionDataDecoder(), + ); +} + +export type WithdrawPayerInput< + TAccountPayer extends string = string, + TAccountChannel extends string = string, + TAccountChannelTokenAccount extends string = string, + TAccountPayerTokenAccount extends string = string, + TAccountMint extends string = string, + TAccountTokenProgram extends string = string, +> = { + payer: TransactionSigner; + channel: Address; + channelTokenAccount: Address; + payerTokenAccount: Address; + mint: Address; + tokenProgram: Address; +}; + +export function getWithdrawPayerInstruction< + TAccountPayer extends string, + TAccountChannel extends string, + TAccountChannelTokenAccount extends string, + TAccountPayerTokenAccount extends string, + TAccountMint extends string, + TAccountTokenProgram extends string, + TProgramAddress extends Address = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, +>( + input: WithdrawPayerInput< + TAccountPayer, + TAccountChannel, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountMint, + TAccountTokenProgram + >, + config?: { programAddress?: TProgramAddress }, +): WithdrawPayerInstruction< + TProgramAddress, + TAccountPayer, + TAccountChannel, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountMint, + TAccountTokenProgram +> { + // Program address. + const programAddress = config?.programAddress ?? PAYMENT_CHANNELS_PROGRAM_ADDRESS; + + // Original accounts. + const originalAccounts = { + payer: { value: input.payer ?? null, isWritable: false }, + channel: { value: input.channel ?? null, isWritable: true }, + channelTokenAccount: { + value: input.channelTokenAccount ?? null, + isWritable: true, + }, + payerTokenAccount: { + value: input.payerTokenAccount ?? null, + isWritable: true, + }, + mint: { value: input.mint ?? null, isWritable: false }, + tokenProgram: { value: input.tokenProgram ?? null, isWritable: false }, + }; + const accounts = originalAccounts as Record< + keyof typeof originalAccounts, + ResolvedInstructionAccount + >; + + const getAccountMeta = getAccountMetaFactory(programAddress, "programId"); + return Object.freeze({ + accounts: [ + getAccountMeta("payer", accounts.payer), + getAccountMeta("channel", accounts.channel), + getAccountMeta("channelTokenAccount", accounts.channelTokenAccount), + getAccountMeta("payerTokenAccount", accounts.payerTokenAccount), + getAccountMeta("mint", accounts.mint), + getAccountMeta("tokenProgram", accounts.tokenProgram), + ], + data: getWithdrawPayerInstructionDataEncoder().encode({}), + programAddress, + } as WithdrawPayerInstruction< + TProgramAddress, + TAccountPayer, + TAccountChannel, + TAccountChannelTokenAccount, + TAccountPayerTokenAccount, + TAccountMint, + TAccountTokenProgram + >); +} + +export type ParsedWithdrawPayerInstruction< + TProgram extends string = typeof PAYMENT_CHANNELS_PROGRAM_ADDRESS, + TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[], +> = { + programAddress: Address; + accounts: { + payer: TAccountMetas[0]; + channel: TAccountMetas[1]; + channelTokenAccount: TAccountMetas[2]; + payerTokenAccount: TAccountMetas[3]; + mint: TAccountMetas[4]; + tokenProgram: TAccountMetas[5]; + }; + data: WithdrawPayerInstructionData; +}; + +export function parseWithdrawPayerInstruction< + TProgram extends string, + TAccountMetas extends readonly AccountMeta[], +>( + instruction: Instruction & + InstructionWithAccounts & + InstructionWithData, +): ParsedWithdrawPayerInstruction { + if (instruction.accounts.length < 6) { + throw new Error(`insufficient account metas: expected 6, got ${instruction.accounts.length}`); + } + let accountIndex = 0; + const getNextAccount = () => { + const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!; + accountIndex += 1; + return accountMeta; + }; + return { + programAddress: instruction.programAddress, + accounts: { + payer: getNextAccount(), + channel: getNextAccount(), + channelTokenAccount: getNextAccount(), + payerTokenAccount: getNextAccount(), + mint: getNextAccount(), + tokenProgram: getNextAccount(), + }, + data: getWithdrawPayerInstructionDataDecoder().decode(instruction.data), + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/eventAuthority.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/eventAuthority.ts index d4199099e6..2e3f5fd280 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/eventAuthority.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/eventAuthority.ts @@ -1,20 +1,18 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/pdas/eventAuthority.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama */ import { - type Address, getProgramDerivedAddress, getUtf8Encoder, + type Address, type ProgramDerivedAddress, } from "@solana/kit"; -/** - * - * @param config - * @param config.programAddress - */ export async function findEventAuthorityPda( config: { programAddress?: Address | undefined } = {}, ): Promise { diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/index.ts index ab40a652d7..0bb1fdc652 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/index.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/pdas/index.ts @@ -1 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + export * from "./eventAuthority"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/index.ts index 54fb54259d..254b137a1b 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/index.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/index.ts @@ -1 +1,9 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + export * from "./paymentChannels"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/paymentChannels.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/paymentChannels.ts index 3ebec61e2a..80ad1ab8f3 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/paymentChannels.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/programs/paymentChannels.ts @@ -1,9 +1,6 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/programs/paymentChannels.ts - * - * Minimal subset: only the program-address constant is needed by the - * instruction builders. + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead rerun the payment-channels generator. */ import type { Address } from "@solana/kit"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/safe-codecs.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/safe-codecs.ts deleted file mode 100644 index 4cb8753567..0000000000 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/safe-codecs.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/safe-codecs.ts - * - * Drop-in replacement for @solana/kit's number codecs. Re-exports everything - * unchanged except the u64/u128/i64/i128 encoders, which reject unsafe-integer - * JS numbers at runtime and narrow the input type to plain `bigint`. - */ - -import { - type FixedSizeEncoder, - getI64Encoder as kitI64Encoder, - getI128Encoder as kitI128Encoder, - getU64Encoder as kitU64Encoder, - getU128Encoder as kitU128Encoder, -} from "@solana/kit"; - -export * from "@solana/kit"; - -const U64_MAX = 0xffffffffffffffffn; -const U128_MAX = 0xffffffffffffffffffffffffffffffffn; -const I64_MIN = -0x8000000000000000n; -const I64_MAX = 0x7fffffffffffffffn; -const I128_MIN = -0x80000000000000000000000000000000n; -const I128_MAX = 0x7fffffffffffffffffffffffffffffffn; - -/** - * - * @param label - * @param value - * @param min - * @param max - */ -function coerce(label: string, value: bigint | number, min: bigint, max: bigint): bigint { - if (typeof value === "bigint") { - if (value < min || value > max) { - throw new RangeError(`${label}: value ${value} out of range [${min}, ${max}]`); - } - return value; - } - - if (typeof value !== "number") { - throw new TypeError(`${label}: expected bigint, got ${typeof value}`); - } - - if (!Number.isSafeInteger(value)) { - throw new TypeError( - `${label}: expected bigint; got number ${value}. ` + - "JS numbers above 2^53-1 round silently, pass BigInt(...) instead.", - ); - } - - const bn = BigInt(value); - if (bn < min || bn > max) { - throw new RangeError(`${label}: value ${bn} out of range [${min}, ${max}]`); - } - return bn; -} - -/** - * - * @param label - * @param inner - * @param min - * @param max - */ -function guardEncoder( - label: string, - inner: FixedSizeEncoder, - min: bigint, - max: bigint, -): FixedSizeEncoder { - return { - encode(value) { - return inner.encode(coerce(label, value as bigint | number, min, max)); - }, - fixedSize: inner.fixedSize, - write(value, bytes, offset) { - return inner.write(coerce(label, value as bigint | number, min, max), bytes, offset); - }, - }; -} - -/** - * - */ -export function getU64Encoder(): FixedSizeEncoder { - return guardEncoder("u64", kitU64Encoder(), 0n, U64_MAX); -} - -/** - * - */ -export function getU128Encoder(): FixedSizeEncoder { - return guardEncoder("u128", kitU128Encoder(), 0n, U128_MAX); -} - -/** - * - */ -export function getI64Encoder(): FixedSizeEncoder { - return guardEncoder("i64", kitI64Encoder(), I64_MIN, I64_MAX); -} - -/** - * - */ -export function getI128Encoder(): FixedSizeEncoder { - return guardEncoder("i128", kitI128Encoder(), I128_MIN, I128_MAX); -} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/accountDiscriminator.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/accountDiscriminator.ts new file mode 100644 index 0000000000..55e71ce289 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/accountDiscriminator.ts @@ -0,0 +1,38 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export enum AccountDiscriminator { + Channel = 1, + ClosedChannel = 2, +} + +export type AccountDiscriminatorArgs = AccountDiscriminator; + +export function getAccountDiscriminatorEncoder(): FixedSizeEncoder { + return getEnumEncoder(AccountDiscriminator); +} + +export function getAccountDiscriminatorDecoder(): FixedSizeDecoder { + return getEnumDecoder(AccountDiscriminator); +} + +export function getAccountDiscriminatorCodec(): FixedSizeCodec< + AccountDiscriminatorArgs, + AccountDiscriminator +> { + return combineCodec(getAccountDiscriminatorEncoder(), getAccountDiscriminatorDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/channelStatus.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/channelStatus.ts new file mode 100644 index 0000000000..0649511fd4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/channelStatus.ts @@ -0,0 +1,37 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export enum ChannelStatus { + Open = 0, + Sealed = 1, + Closing = 2, + Distributed = 3, +} + +export type ChannelStatusArgs = ChannelStatus; + +export function getChannelStatusEncoder(): FixedSizeEncoder { + return getEnumEncoder(ChannelStatus); +} + +export function getChannelStatusDecoder(): FixedSizeDecoder { + return getEnumDecoder(ChannelStatus); +} + +export function getChannelStatusCodec(): FixedSizeCodec { + return combineCodec(getChannelStatusEncoder(), getChannelStatusDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributeArgs.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributeArgs.ts index 52ca35189c..68db24ff78 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributeArgs.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributeArgs.ts @@ -1,47 +1,40 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/types/distributeArgs.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama */ import { - type Codec, combineCodec, - type Decoder, - type Encoder, getArrayDecoder, getArrayEncoder, getStructDecoder, getStructEncoder, + type Codec, + type Decoder, + type Encoder, } from "@solana/kit"; - import { - type DistributionEntry, - type DistributionEntryArgs, getDistributionEntryDecoder, getDistributionEntryEncoder, -} from "./distributionEntry"; + type DistributionEntry, + type DistributionEntryArgs, +} from "."; export type DistributeArgs = { recipients: Array }; export type DistributeArgsArgs = { recipients: Array }; -/** - * - */ export function getDistributeArgsEncoder(): Encoder { return getStructEncoder([["recipients", getArrayEncoder(getDistributionEntryEncoder())]]); } -/** - * - */ export function getDistributeArgsDecoder(): Decoder { return getStructDecoder([["recipients", getArrayDecoder(getDistributionEntryDecoder())]]); } -/** - * - */ export function getDistributeArgsCodec(): Codec { return combineCodec(getDistributeArgsEncoder(), getDistributeArgsDecoder()); } diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributionEntry.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributionEntry.ts index 32034b0735..59f5dc4ebe 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributionEntry.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/distributionEntry.ts @@ -1,29 +1,29 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/types/distributionEntry.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama */ import { - type Address, combineCodec, - type FixedSizeCodec, - type FixedSizeDecoder, - type FixedSizeEncoder, getAddressDecoder, getAddressEncoder, getStructDecoder, getStructEncoder, + getU16Decoder, + getU16Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, } from "@solana/kit"; -import { getU16Decoder, getU16Encoder } from "../safe-codecs"; - -export type DistributionEntry = { bps: number; recipient: Address }; +export type DistributionEntry = { recipient: Address; bps: number }; export type DistributionEntryArgs = DistributionEntry; -/** - * - */ export function getDistributionEntryEncoder(): FixedSizeEncoder { return getStructEncoder([ ["recipient", getAddressEncoder()], @@ -31,9 +31,6 @@ export function getDistributionEntryEncoder(): FixedSizeEncoder { return getStructDecoder([ ["recipient", getAddressDecoder()], @@ -41,9 +38,6 @@ export function getDistributionEntryDecoder(): FixedSizeDecoder; - salt: bigint; }; export type OpenArgsArgs = { - deposit: bigint; + salt: number | bigint; + deposit: number | bigint; gracePeriod: number; - openSlot: bigint; + openSlot: number | bigint; recipients: Array; - salt: bigint; }; -/** - * - */ export function getOpenArgsEncoder(): Encoder { return getStructEncoder([ ["salt", getU64Encoder()], @@ -51,9 +53,6 @@ export function getOpenArgsEncoder(): Encoder { ]); } -/** - * - */ export function getOpenArgsDecoder(): Decoder { return getStructDecoder([ ["salt", getU64Decoder()], @@ -64,9 +63,6 @@ export function getOpenArgsDecoder(): Decoder { ]); } -/** - * - */ export function getOpenArgsCodec(): Codec { return combineCodec(getOpenArgsEncoder(), getOpenArgsDecoder()); } diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/opened.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/opened.ts new file mode 100644 index 0000000000..73bbc65c72 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/opened.ts @@ -0,0 +1,43 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export type Opened = { channel: Address; openSlot: bigint }; + +export type OpenedArgs = { channel: Address; openSlot: number | bigint }; + +export function getOpenedEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ["channel", getAddressEncoder()], + ["openSlot", getU64Encoder()], + ]); +} + +export function getOpenedDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ["channel", getAddressDecoder()], + ["openSlot", getU64Decoder()], + ]); +} + +export function getOpenedCodec(): FixedSizeCodec { + return combineCodec(getOpenedEncoder(), getOpenedDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutBeneficiary.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutBeneficiary.ts new file mode 100644 index 0000000000..2789fe85e0 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutBeneficiary.ts @@ -0,0 +1,39 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export enum PayoutBeneficiary { + Recipient = 0, + Payee = 1, + Payer = 2, +} + +export type PayoutBeneficiaryArgs = PayoutBeneficiary; + +export function getPayoutBeneficiaryEncoder(): FixedSizeEncoder { + return getEnumEncoder(PayoutBeneficiary); +} + +export function getPayoutBeneficiaryDecoder(): FixedSizeDecoder { + return getEnumDecoder(PayoutBeneficiary); +} + +export function getPayoutBeneficiaryCodec(): FixedSizeCodec< + PayoutBeneficiaryArgs, + PayoutBeneficiary +> { + return combineCodec(getPayoutBeneficiaryEncoder(), getPayoutBeneficiaryDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutRedirected.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutRedirected.ts new file mode 100644 index 0000000000..fe86b1e88b --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/payoutRedirected.ts @@ -0,0 +1,71 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getAddressDecoder, + getAddressEncoder, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; +import { + getPayoutBeneficiaryDecoder, + getPayoutBeneficiaryEncoder, + getRedirectReasonDecoder, + getRedirectReasonEncoder, + type PayoutBeneficiary, + type PayoutBeneficiaryArgs, + type RedirectReason, + type RedirectReasonArgs, +} from "."; + +export type PayoutRedirected = { + channel: Address; + owner: Address; + amount: bigint; + beneficiary: PayoutBeneficiary; + reason: RedirectReason; +}; + +export type PayoutRedirectedArgs = { + channel: Address; + owner: Address; + amount: number | bigint; + beneficiary: PayoutBeneficiaryArgs; + reason: RedirectReasonArgs; +}; + +export function getPayoutRedirectedEncoder(): FixedSizeEncoder { + return getStructEncoder([ + ["channel", getAddressEncoder()], + ["owner", getAddressEncoder()], + ["amount", getU64Encoder()], + ["beneficiary", getPayoutBeneficiaryEncoder()], + ["reason", getRedirectReasonEncoder()], + ]); +} + +export function getPayoutRedirectedDecoder(): FixedSizeDecoder { + return getStructDecoder([ + ["channel", getAddressDecoder()], + ["owner", getAddressDecoder()], + ["amount", getU64Decoder()], + ["beneficiary", getPayoutBeneficiaryDecoder()], + ["reason", getRedirectReasonDecoder()], + ]); +} + +export function getPayoutRedirectedCodec(): FixedSizeCodec { + return combineCodec(getPayoutRedirectedEncoder(), getPayoutRedirectedDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/redirectReason.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/redirectReason.ts new file mode 100644 index 0000000000..3a939ce873 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/redirectReason.ts @@ -0,0 +1,37 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getEnumDecoder, + getEnumEncoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export enum RedirectReason { + UnsupportedExtension = 0, + ClosedOrMalformed = 1, + NotInitialized = 2, + ReassignedAuthority = 3, +} + +export type RedirectReasonArgs = RedirectReason; + +export function getRedirectReasonEncoder(): FixedSizeEncoder { + return getEnumEncoder(RedirectReason); +} + +export function getRedirectReasonDecoder(): FixedSizeDecoder { + return getEnumDecoder(RedirectReason); +} + +export function getRedirectReasonCodec(): FixedSizeCodec { + return combineCodec(getRedirectReasonEncoder(), getRedirectReasonDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/settleAndSealArgs.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/settleAndSealArgs.ts index ff9837eb70..535f63a053 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/settleAndSealArgs.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/settleAndSealArgs.ts @@ -1,44 +1,34 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/types/settleAndSealArgs.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama */ import { combineCodec, + getStructDecoder, + getStructEncoder, + getU8Decoder, + getU8Encoder, type FixedSizeCodec, type FixedSizeDecoder, type FixedSizeEncoder, - getStructDecoder, - getStructEncoder, } from "@solana/kit"; -import { getU8Decoder, getU8Encoder } from "../safe-codecs"; - -export type SettleAndSealArgs = { - hasVoucher: number; -}; +export type SettleAndSealArgs = { hasVoucher: number }; -export type SettleAndSealArgsArgs = { - hasVoucher: number; -}; +export type SettleAndSealArgsArgs = SettleAndSealArgs; -/** - * - */ export function getSettleAndSealArgsEncoder(): FixedSizeEncoder { return getStructEncoder([["hasVoucher", getU8Encoder()]]); } -/** - * - */ export function getSettleAndSealArgsDecoder(): FixedSizeDecoder { return getStructDecoder([["hasVoucher", getU8Decoder()]]); } -/** - * - */ export function getSettleAndSealArgsCodec(): FixedSizeCodec< SettleAndSealArgsArgs, SettleAndSealArgs diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/topUpArgs.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/topUpArgs.ts new file mode 100644 index 0000000000..f5706d8f48 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/topUpArgs.ts @@ -0,0 +1,34 @@ +/** + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama + */ + +import { + combineCodec, + getStructDecoder, + getStructEncoder, + getU64Decoder, + getU64Encoder, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, +} from "@solana/kit"; + +export type TopUpArgs = { amount: bigint }; + +export type TopUpArgsArgs = { amount: number | bigint }; + +export function getTopUpArgsEncoder(): FixedSizeEncoder { + return getStructEncoder([["amount", getU64Encoder()]]); +} + +export function getTopUpArgsDecoder(): FixedSizeDecoder { + return getStructDecoder([["amount", getU64Decoder()]]); +} + +export function getTopUpArgsCodec(): FixedSizeCodec { + return combineCodec(getTopUpArgsEncoder(), getTopUpArgsDecoder()); +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/voucherArgs.ts b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/voucherArgs.ts index ba2f3f3d23..f3251a3d13 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/voucherArgs.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/generated/types/voucherArgs.ts @@ -1,26 +1,31 @@ /** - * Vendored from - * https://github.com/solana-foundation/payment-channel/blob/main/clients/typescript/src/generated/types/voucherArgs.ts + * This code was AUTOGENERATED using the Codama library. + * Please DO NOT EDIT THIS FILE, instead use visitors + * to add features, then rerun Codama to update it. + * + * @see https://github.com/codama-idl/codama */ import { - type Address, combineCodec, - type FixedSizeCodec, - type FixedSizeDecoder, - type FixedSizeEncoder, getAddressDecoder, getAddressEncoder, getArrayDecoder, getArrayEncoder, + getI64Decoder, + getI64Encoder, getStructDecoder, getStructEncoder, + getU64Decoder, + getU64Encoder, getU8Decoder, getU8Encoder, + type Address, + type FixedSizeCodec, + type FixedSizeDecoder, + type FixedSizeEncoder, } from "@solana/kit"; -import { getI64Decoder, getI64Encoder, getU64Decoder, getU64Encoder } from "../safe-codecs"; - export type VoucherArgs = { magic: Array; channelId: Address; @@ -28,11 +33,13 @@ export type VoucherArgs = { expiresAt: bigint; }; -export type VoucherArgsArgs = VoucherArgs; +export type VoucherArgsArgs = { + magic: Array; + channelId: Address; + cumulativeAmount: number | bigint; + expiresAt: number | bigint; +}; -/** - * - */ export function getVoucherArgsEncoder(): FixedSizeEncoder { return getStructEncoder([ ["magic", getArrayEncoder(getU8Encoder(), { size: 2 })], @@ -42,9 +49,6 @@ export function getVoucherArgsEncoder(): FixedSizeEncoder { ]); } -/** - * - */ export function getVoucherArgsDecoder(): FixedSizeDecoder { return getStructDecoder([ ["magic", getArrayDecoder(getU8Decoder(), { size: 2 })], @@ -54,9 +58,6 @@ export function getVoucherArgsDecoder(): FixedSizeDecoder { ]); } -/** - * - */ export function getVoucherArgsCodec(): FixedSizeCodec { return combineCodec(getVoucherArgsEncoder(), getVoucherArgsDecoder()); } diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/index.ts b/typescript/packages/mechanisms/svm/src/payment-channels/index.ts index 07491ea245..049f982cb0 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/index.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/index.ts @@ -5,6 +5,11 @@ */ export * from "./generated/index"; +export * from "./facilitator"; +export * from "./signer"; export * from "./onchain"; export * from "./open"; +export * from "./rentCleanup"; +export * from "./requirements"; +export * from "./storage"; export * from "./voucher"; diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/onchain.ts b/typescript/packages/mechanisms/svm/src/payment-channels/onchain.ts index a0b222121f..bb82fcf416 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/onchain.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/onchain.ts @@ -6,13 +6,11 @@ */ import { - AccountRole, type AccountMeta, type Address, address, getBase58Decoder, getBase58Encoder, - getU8Encoder, type Instruction, type InstructionWithAccounts, type InstructionWithData, @@ -23,20 +21,15 @@ import { findAssociatedTokenPda } from "@solana-program/token-2022"; import { SOLANA_DEVNET_CAIP2 } from "../constants"; import { getDistributeInstruction } from "./generated/instructions/distribute"; +import { getReclaimInstruction, RECLAIM_DISCRIMINATOR } from "./generated/instructions/reclaim"; +import { getSealInstruction } from "./generated/instructions/seal"; +import { getSettleInstruction } from "./generated/instructions/settle"; import { getSettleAndSealInstruction } from "./generated/instructions/settleAndSeal"; import { findEventAuthorityPda } from "./generated/pdas/eventAuthority"; +import { ChannelStatus } from "./generated/types/channelStatus"; import { encodeVoucherMessageBytes } from "./voucher"; -/** Onchain `Channel.status` values (payment-channels program). */ -export enum ChannelStatus { - Open = 0, - Closing = 1, - Sealed = 2, - Distributed = 3, -} - -/** Instruction discriminator for permissionless `reclaim`. */ -export const RECLAIM_DISCRIMINATOR = 9; +export { RECLAIM_DISCRIMINATOR, ChannelStatus }; /** * Concrete instruction shape returned by every builder here: program address, @@ -241,6 +234,73 @@ export function buildSettleAndSealInstructions(args: SettleAndSealBuildArgs): Se return instructions; } +/** + * Build the permissionless seal instruction for a Closing channel whose grace + * period has elapsed. + * + * @param channelId - Payment-channel address + * @returns The seal instruction + */ +export function buildSealInstruction(channelId: string): ServerInstruction { + return getSealInstruction({ channel: address(channelId) }) as unknown as ServerInstruction; +} + +// ───────────────────────────────────────────────────────────────────── +// settle (watermark-only, batched redemption) +// ───────────────────────────────────────────────────────────────────── + +/** Arguments to {@link buildSettleInstructions}. */ +export interface SettleBuildArgs { + /** Payment-channel address being settled (base58). */ + channelId: string; + /** The cumulative voucher to redeem (signed by the channel's authorized signer). */ + voucher: SettleVoucher; + /** Payment-channels program id override. */ + programId?: Address | undefined; +} + +/** + * Build the instruction pair for an on-chain `settle`: an Ed25519 precompile + * verifying the voucher, followed by the `settle` instruction that advances the + * channel's `settled` watermark to the voucher's cumulative amount. Unlike + * {@link buildSettleAndSealInstructions}, this neither seals the channel + * nor moves tokens — it is the batched redemption step. `settle` is + * permissionless; the precompile signature is the authority, so the operator + * only fee-pays. + * + * @param args - Build inputs + * @returns `[ed25519_verify, settle]` in submit order + */ +export function buildSettleInstructions(args: SettleBuildArgs): ServerInstruction[] { + const programId = args.programId ?? PAYMENT_CHANNELS_PROGRAM_ID; + const channel = address(args.channelId); + + const signerBytes = getBase58Bytes(args.voucher.authorizedSigner); + if (signerBytes.byteLength !== 32) { + throw new Error(`authorizedSigner must decode to 32 bytes; got ${signerBytes.byteLength}`); + } + const signatureBytes = getBase58Bytes(args.voucher.signatureBase58); + if (signatureBytes.byteLength !== 64) { + throw new Error(`voucher signature must decode to 64 bytes; got ${signatureBytes.byteLength}`); + } + const message = encodeVoucherMessageBytes({ + channelId: args.channelId, + cumulativeAmount: args.voucher.cumulativeAmount, + expiresAt: args.voucher.expiresAt, + }); + + const verify = buildEd25519VerifyInstruction({ + message, + signature: signatureBytes, + signer: signerBytes, + }); + const settle = getSettleInstruction( + { channel, instructionsSysvar: INSTRUCTIONS_SYSVAR_ADDRESS }, + { programAddress: programId }, + ); + return [verify, settle as unknown as ServerInstruction]; +} + // ───────────────────────────────────────────────────────────────────── // distribute // ───────────────────────────────────────────────────────────────────── @@ -344,14 +404,13 @@ export interface ReclaimBuildArgs { */ export function buildReclaimInstruction(args: ReclaimBuildArgs): ServerInstruction { const programId = args.programId ?? PAYMENT_CHANNELS_PROGRAM_ID; - return { - accounts: [ - { address: address(args.channelId), role: AccountRole.WRITABLE }, - { address: address(args.rentPayer), role: AccountRole.WRITABLE }, - ], - data: getU8Encoder().encode(RECLAIM_DISCRIMINATOR), - programAddress: programId, - } as ServerInstruction; + return getReclaimInstruction( + { + channel: address(args.channelId), + rentPayer: address(args.rentPayer), + }, + { programAddress: programId }, + ) as unknown as ServerInstruction; } // ───────────────────────────────────────────────────────────────────── diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/open.ts b/typescript/packages/mechanisms/svm/src/payment-channels/open.ts index 14f411a67e..7df6bded6b 100644 --- a/typescript/packages/mechanisms/svm/src/payment-channels/open.ts +++ b/typescript/packages/mechanisms/svm/src/payment-channels/open.ts @@ -50,6 +50,11 @@ import { getOpenInstructionDataDecoder, OPEN_DISCRIMINATOR, } from "./generated/instructions/open"; +import { + getTopUpInstruction, + getTopUpInstructionDataDecoder, + TOP_UP_DISCRIMINATOR, +} from "./generated/instructions/topUp"; import { findEventAuthorityPda } from "./generated/pdas/eventAuthority"; import { ASSOCIATED_TOKEN_PROGRAM_ID, PAYMENT_CHANNELS_PROGRAM_ID } from "./onchain"; import { verifyEd25519Signature } from "./voucher"; @@ -58,22 +63,20 @@ const U64_MAX = (1n << 64n) - 1n; /** Spec ceiling for `SetComputeUnitLimit` on an open transaction. */ export const OPEN_MAX_COMPUTE_UNIT_LIMIT = 400_000; /** - * Default `SetComputeUnitLimit` for a built open transaction. Without one the - * runtime reserves 200,000 CU per instruction (SIMD-0170) — 400,000 for the - * open + memo pair — while an observed open consumes ~51,000 CU. The default - * keeps ~1.8x headroom over that, and any `SetComputeUnitPrice` priority fee - * is charged on the requested limit, so right-sizing buys the same scheduling - * priority at a fraction of the fee. Assumes standard SPL Token (or - * Token-2022 without execution extensions) behavior — mints whose escrow - * transfer runs compute-heavy extensions (e.g. transfer hooks) need an - * explicit {@link BuildOpenArgs.computeUnitLimit} override, up to the spec - * ceiling {@link OPEN_MAX_COMPUTE_UNIT_LIMIT}. + * Default `SetComputeUnitLimit` for a built open or top-up. Bump-seed search + * (1,500 CU per rejected candidate) plus the nonce and binding Memos can + * exceed 110,000 CU; 200,000 leaves room for a longer streak. Priority fee is + * charged on the requested limit. Mints with compute-heavy transfer extensions + * need an explicit {@link BuildOpenArgs.computeUnitLimit}, up to + * {@link OPEN_MAX_COMPUTE_UNIT_LIMIT}. */ -export const OPEN_DEFAULT_COMPUTE_UNIT_LIMIT = 90_000; +export const OPEN_DEFAULT_COMPUTE_UNIT_LIMIT = 200_000; /** Spec ceiling for optional Phantom/Solflare Lighthouse assertions after `open`. */ const OPEN_MAX_LIGHTHOUSE_INSTRUCTIONS = 3; -/** Max optional suffix length after `open` (3 Lighthouse + 1 Memo). */ +/** Max optional suffix length after `open` (3 Lighthouse + 1 Memo), plus one binding memo when expected. */ const OPEN_MAX_OPTIONAL_SUFFIX = 4; +/** Solana packet limit for a serialized transaction. */ +const MAX_TRANSACTION_BYTES = 1232; /** * Slot freshness / reclaim gate window for payment-channel PDAs. @@ -133,6 +136,8 @@ export interface BuildOpenArgs { * data; otherwise a random hex nonce is emitted for uniqueness. */ memo?: string | undefined; + /** Optional already-encoded binding memo, emitted as a second Memo instruction. */ + bindingMemo?: string | undefined; /** * `SetComputeUnitLimit` units for the transaction. Defaults to * {@link OPEN_DEFAULT_COMPUTE_UNIT_LIMIT}; `0` omits the instruction (the @@ -165,6 +170,131 @@ export interface BuiltOpen { openSlot: bigint; } +/** Parameters for a canonical payment-channel `top_up` transaction. */ +export interface BuildTopUpArgs { + payer: TransactionSigner; + channelId: string; + mint: string; + tokenProgram: string; + feePayer: string; + amount: bigint; + blockhash: { blockhash: string; lastValidBlockHeight: bigint }; + memo?: string | undefined; + computeUnitLimit?: number | undefined; + computeUnitPriceMicroLamports?: number | undefined; + programId?: string | undefined; +} + +/** Result of {@link buildTopUpPaymentChannelTransaction}. */ +export interface BuiltTopUp { + channelId: string; + amount: bigint; + transaction: string; +} + +/** + * Build the payer-signed canonical `top_up` transaction. The fee payer is + * deliberately only the transaction fee payer: it is not an instruction + * account in the six-account top-up layout. + */ +export async function buildTopUpPaymentChannelTransaction( + args: BuildTopUpArgs, +): Promise { + if (args.amount <= 0n) throw new Error("top-up amount must be positive"); + const programAddress = address(args.programId ?? PAYMENT_CHANNELS_PROGRAM_ID); + const mint = address(args.mint); + const tokenProgram = address(args.tokenProgram); + const [payerTokenAccount] = await findAssociatedTokenPda({ + mint, + owner: args.payer.address, + tokenProgram, + }); + const [channelTokenAccount] = await findAssociatedTokenPda({ + mint, + owner: address(args.channelId), + tokenProgram, + }); + const instruction = getTopUpInstruction( + { + channel: address(args.channelId), + channelTokenAccount, + mint, + payer: args.payer, + payerTokenAccount, + tokenProgram, + topUpArgs: { amount: args.amount }, + }, + { programAddress }, + ); + const computeUnitLimit = args.computeUnitLimit ?? OPEN_DEFAULT_COMPUTE_UNIT_LIMIT; + const computeUnitPrice = + args.computeUnitPriceMicroLamports ?? DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS; + if ( + !Number.isSafeInteger(computeUnitLimit) || + computeUnitLimit < 0 || + computeUnitLimit > OPEN_MAX_COMPUTE_UNIT_LIMIT + ) { + throw new Error(`computeUnitLimit must be an integer in [0, ${OPEN_MAX_COMPUTE_UNIT_LIMIT}]`); + } + if ( + !Number.isSafeInteger(computeUnitPrice) || + computeUnitPrice < 0 || + computeUnitPrice > MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + ) { + throw new Error( + `computeUnitPriceMicroLamports must be an integer in [0, ${MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS}]`, + ); + } + const memo = + args.memo ?? + Array.from(globalThis.crypto.getRandomValues(new Uint8Array(16))) + .map(byte => byte.toString(16).padStart(2, "0")) + .join(""); + const memoData = new TextEncoder().encode(memo); + if (memoData.byteLength > MAX_MEMO_BYTES) + throw new Error(`extra.memo exceeds maximum ${MAX_MEMO_BYTES} bytes`); + const computeBudgetIxs = [ + ...(computeUnitLimit > 0 + ? [getSetComputeUnitLimitInstruction({ units: computeUnitLimit })] + : []), + ...(computeUnitPrice > 0 + ? [getSetComputeUnitPriceInstruction({ microLamports: computeUnitPrice })] + : []), + ]; + const message = pipe( + createTransactionMessage({ version: 0 }), + msg => setTransactionMessageFeePayer(address(args.feePayer), msg), + msg => + setTransactionMessageLifetimeUsingBlockhash( + { + blockhash: args.blockhash.blockhash as Blockhash, + lastValidBlockHeight: args.blockhash.lastValidBlockHeight, + }, + msg, + ), + msg => + appendTransactionMessageInstructions( + [ + ...computeBudgetIxs, + instruction, + { + accounts: [] as const, + data: memoData, + programAddress: MEMO_PROGRAM_ADDRESS as Address, + }, + ], + msg, + ), + ); + return { + amount: args.amount, + channelId: args.channelId, + transaction: getBase64EncodedWireTransaction( + await partiallySignTransactionMessageWithSigners(message), + ), + }; +} + /** * Derive the channel PDA for the given open parameters. * @@ -298,6 +428,10 @@ export async function buildOpenPaymentChannelTransaction(args: BuildOpenArgs): P accounts: [] as const, data: memoData, }; + const memoIxs = + args.bindingMemo === undefined + ? [memoIx] + : [memoIx, { ...memoIx, data: new TextEncoder().encode(args.bindingMemo) }]; const computeUnitLimit = args.computeUnitLimit ?? OPEN_DEFAULT_COMPUTE_UNIT_LIMIT; if ( @@ -342,16 +476,24 @@ export async function buildOpenPaymentChannelTransaction(args: BuildOpenArgs): P }, msg, ), - msg => appendTransactionMessageInstructions([...computeBudgetIxs, instruction, memoIx], msg), + msg => + appendTransactionMessageInstructions([...computeBudgetIxs, instruction, ...memoIxs], msg), ); const signed = await partiallySignTransactionMessageWithSigners(message); + const transaction = getBase64EncodedWireTransaction(signed); + const size = getBase64Codec().encode(transaction).byteLength; + if (size > MAX_TRANSACTION_BYTES) { + throw new Error( + `open transaction is ${size} bytes, above the ${MAX_TRANSACTION_BYTES}-byte packet limit`, + ); + } return { channelId, deposit: args.deposit, openSlot, salt, - transaction: getBase64EncodedWireTransaction(signed), + transaction, }; } @@ -390,6 +532,11 @@ export interface VerifyOpenExpected { * instruction MUST match this UTF-8 value. */ memo?: string | undefined; + /** + * Binding memo text. When set, exactly one suffix Memo instruction MUST + * match it, and {@link VerifyOpenExpected.memo} applies to the other memos. + */ + expectedBindingMemo?: string | undefined; /** * Operator ceiling for `SetComputeUnitLimit`. Clamped to the spec max * ({@link OPEN_MAX_COMPUTE_UNIT_LIMIT}); unset uses the spec max. @@ -408,6 +555,120 @@ export interface VerifyOpenExpected { maxRequiredSignatures?: number | undefined; } +/** Expected bindings for a canonical six-account `top_up` transaction. */ +export interface VerifyTopUpExpected { + feePayer: string; + from: string; + channelId: string; + mint: string; + tokenProgram: string; + amount: bigint; + memo?: string | undefined; + programId?: string | undefined; + maxComputeUnits?: number | undefined; + maxPriorityFeeMicroLamports?: number | undefined; +} + +/** Decode and validate the canonical top-up transaction layout and bindings. */ +export async function verifyTopUpTransaction( + transactionBase64: string, + expected: VerifyTopUpExpected, +): Promise { + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(transactionBase64)); + const message = getCompiledTransactionMessageDecoder().decode( + decoded.messageBytes, + ) as unknown as CompiledOpenMessage; + if (message.addressTableLookups && message.addressTableLookups.length > 0) { + throw new Error("verifyTopUpTransaction: address-lookup tables are not permitted"); + } + const ix = findCanonicalOpenInstruction( + message, + expected.programId ?? PAYMENT_CHANNELS_PROGRAM_ID, + expected.feePayer, + { + maxComputeUnits: Math.min( + expected.maxComputeUnits ?? OPEN_MAX_COMPUTE_UNIT_LIMIT, + OPEN_MAX_COMPUTE_UNIT_LIMIT, + ), + maxPriorityFeeMicroLamports: Math.min( + expected.maxPriorityFeeMicroLamports ?? MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + ), + expectedMemo: expected.memo, + }, + TOP_UP_DISCRIMINATOR, + "top_up", + ); + if (message.staticAccounts[0] !== expected.feePayer) + throw new Error("verifyTopUpTransaction: fee payer mismatch"); + if (message.header.numSignerAccounts !== (expected.feePayer === expected.from ? 1 : 2)) { + throw new Error("verifyTopUpTransaction: unexpected required signer set"); + } + const signature = decoded.signatures[expected.from as Address]; + if ( + !signature || + !(await verifyEd25519Signature({ + message: decoded.messageBytes as unknown as Uint8Array, + publicKey: getBase58Encoder().encode(expected.from) as Uint8Array, + signature: signature as Uint8Array, + })) + ) { + throw new Error("verifyTopUpTransaction: missing or invalid payer signature"); + } + const indices = ix.accountIndices; + if (indices.length !== 6) + throw new Error( + `verifyTopUpTransaction: top_up must have exactly 6 accounts, found ${indices.length}`, + ); + const account = (index: number): string => { + const value = message.staticAccounts[indices[index] ?? -1]; + if (!value) throw new Error(`verifyTopUpTransaction: missing account ${index}`); + return value; + }; + const [payer, channel, payerAta, channelAta, mint, tokenProgram] = [ + account(0), + account(1), + account(2), + account(3), + account(4), + account(5), + ]; + if ( + payer !== expected.from || + channel !== expected.channelId || + mint !== expected.mint || + tokenProgram !== expected.tokenProgram + ) + throw new Error("verifyTopUpTransaction: account binding mismatch"); + if (indices.includes(0)) + throw new Error("verifyTopUpTransaction: fee payer must not be a top_up account"); + const role = (i: number) => staticAccountRole(message.header, message.staticAccounts.length, i); + for (const [slot, label] of [ + [0, "payer"], + [1, "channel"], + [2, "payer token account"], + [3, "channel token account"], + ] as const) { + const accountIndex = indices[slot]!; + if (!isWritableRole(role(accountIndex)) || (slot === 0 && !isSignerRole(role(accountIndex)))) + throw new Error(`verifyTopUpTransaction: ${label} privilege mismatch`); + } + const [expectedPayerAta] = await findAssociatedTokenPda({ + mint: address(expected.mint), + owner: address(payer), + tokenProgram: address(expected.tokenProgram), + }); + const [expectedChannelAta] = await findAssociatedTokenPda({ + mint: address(expected.mint), + owner: address(channel), + tokenProgram: address(expected.tokenProgram), + }); + if (payerAta !== expectedPayerAta || channelAta !== expectedChannelAta) + throw new Error("verifyTopUpTransaction: ATA binding mismatch"); + if (getTopUpInstructionDataDecoder().decode(ix.data).topUpArgs.amount !== expected.amount) + throw new Error("verifyTopUpTransaction: top-up amount mismatch"); +} + /** Channel facts extracted from a verified open transaction. */ export interface VerifyOpenResult { channelId: string; @@ -474,7 +735,7 @@ export async function verifyOpenTransaction( // Wallets like Phantom/Solflare inject Lighthouse around the client open. if (message.addressTableLookups && message.addressTableLookups.length > 0) { throw new Error( - "verifyOpenTransaction: address lookup tables are not permitted in an open transaction", + "verifyOpenTransaction: address-lookup tables are not permitted in an open transaction — all accounts must be static so the fee-payer guard can validate them", ); } @@ -491,6 +752,7 @@ export async function verifyOpenTransaction( maxComputeUnits, maxPriorityFeeMicroLamports, expectedMemo: expected.memo, + expectedBindingMemo: expected.expectedBindingMemo, }); // Required-signer set must equal the distinct addresses in @@ -760,6 +1022,7 @@ type OpenLayoutLimits = { maxComputeUnits: number; maxPriorityFeeMicroLamports: number; expectedMemo?: string | undefined; + expectedBindingMemo?: string | undefined; }; /** @@ -778,6 +1041,8 @@ function findCanonicalOpenInstruction( programIdStr: string, feePayer: string, limits: OpenLayoutLimits, + discriminator = OPEN_DISCRIMINATOR, + instructionName = "open", ): { accountIndices: readonly number[]; data: Uint8Array } { const { instructions, staticAccounts } = message; if (instructions.length === 0) { @@ -857,23 +1122,26 @@ function findCanonicalOpenInstruction( if ( !openInstruction.data || openInstruction.data.length < 1 || - openInstruction.data[0] !== OPEN_DISCRIMINATOR + openInstruction.data[0] !== discriminator ) { - throw new Error("verifyOpenTransaction: payment-channels instruction is not `open`"); + throw new Error( + `verifyOpenTransaction: payment-channels instruction is not \`${instructionName}\``, + ); } i += 1; let lighthouseCount = 0; let optionalCount = 0; + const maxOptional = OPEN_MAX_OPTIONAL_SUFFIX + (limits.expectedBindingMemo === undefined ? 0 : 1); const memoDatas: Uint8Array[] = []; while (i < instructions.length) { const ix = instructions[i]; if (!ix) break; const program = staticAccounts[ix.programAddressIndex]; optionalCount += 1; - if (optionalCount > OPEN_MAX_OPTIONAL_SUFFIX) { + if (optionalCount > maxOptional) { throw new Error( - `verifyOpenTransaction: at most ${OPEN_MAX_OPTIONAL_SUFFIX} optional instructions are allowed after open`, + `verifyOpenTransaction: at most ${maxOptional} optional instructions are allowed after open`, ); } if (program === LIGHTHOUSE_PROGRAM_ADDRESS) { @@ -896,13 +1164,31 @@ function findCanonicalOpenInstruction( i += 1; } + let otherMemos = memoDatas; + if (limits.expectedBindingMemo !== undefined) { + const expectedBinding = limits.expectedBindingMemo; + const prefixEnd = expectedBinding.lastIndexOf(":"); + const prefix = prefixEnd === -1 ? expectedBinding : expectedBinding.slice(0, prefixEnd + 1); + const decode = (data: Uint8Array) => new TextDecoder().decode(data); + const exact = memoDatas.filter(data => decode(data) === expectedBinding).length; + // Any other memo under the same prefix is a second binding, so recovery + // would not know which key the payer committed to. + const prefixed = memoDatas.filter(data => decode(data).startsWith(prefix)).length; + if (exact !== 1 || prefixed !== 1) { + throw new Error( + `verifyOpenTransaction: expected exactly one Memo instruction matching the receiver binding, found ${exact === 1 ? prefixed : exact}`, + ); + } + otherMemos = memoDatas.filter(data => !decode(data).startsWith(prefix)); + } + if (limits.expectedMemo !== undefined) { - if (memoDatas.length !== 1) { + if (otherMemos.length !== 1) { throw new Error( - `verifyOpenTransaction: expected exactly one Memo instruction matching extra.memo, found ${memoDatas.length}`, + `verifyOpenTransaction: expected exactly one Memo instruction matching extra.memo, found ${otherMemos.length}`, ); } - const actualMemo = new TextDecoder().decode(memoDatas[0]!); + const actualMemo = new TextDecoder().decode(otherMemos[0]!); if (actualMemo !== limits.expectedMemo) { throw new Error("verifyOpenTransaction: Memo instruction data does not match extra.memo"); } @@ -1007,7 +1293,7 @@ export function parseU64(value: bigint | number | string, name: string): bigint * * @returns A random u64 bigint */ -export function randomU64(): bigint { +function randomU64(): bigint { const bytes = new Uint8Array(8); globalThis.crypto.getRandomValues(bytes); return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getBigUint64(0, true); diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/rentCleanup.ts b/typescript/packages/mechanisms/svm/src/payment-channels/rentCleanup.ts new file mode 100644 index 0000000000..e41c7c7720 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/rentCleanup.ts @@ -0,0 +1,1045 @@ +/** + * Facilitator-side async rent cleanup for SVM payment channels. + * + * Driven entirely by verify-time channel storage: each pass lists stored + * channels, refetches live account status, then acts on whatever is ready + * (abandon-close / forced-close / distribute / reclaim). RPC is not used for + * discovery. + * + * Signs with the live channel `payee` / `rent_payer` (same key in this scheme) + * from the facilitator's existing signer pool — no dedicated cleanup key. + * + * Two scheme policies share this worker: + * - `expiry` (`upto`): an Open channel is abandoned at `expiresAt + grace`. + * `expiresAt === 0` is already due. Closing channels are left alone. + * - `idle` (batch settlement, the default): non-expiring vouchers + * (`expiresAt === 0`) abandon only after `maxIdleSecs` without + * facilitator-visible lifecycle activity — the window advertised as + * `extra.maxIdleSecs`. Closing channels are sealed once their onchain grace + * elapses. + */ + +import { address, type Address, type Signature } from "@solana/kit"; +import type { Network } from "@x402/core/types"; + +import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../signer"; +import { BASIS_POINTS_DENOMINATOR, SLOT_COMMITMENT, STATE_COMMITMENT } from "./commitments"; +import { discoverChannelsByRentPayer } from "./discovery"; +import { + type PaymentChannelSvmSigner, + reclaimComputeUnitLimit, + submitChannelTransactionWithSigner, +} from "./facilitator"; +import { fetchMaybeChannel, type Channel } from "./generated/accounts/channel"; +import { + buildDistributeInstruction, + buildReclaimInstruction, + buildSealInstruction, + buildSettleAndSealInstructions, + ChannelStatus, + type ServerInstruction, +} from "./onchain"; +import { OPEN_SLOT_WINDOW } from "./open"; +import { + accountFetchRpc, + assertPaymentChannelFacilitatorSigner, + type PaymentChannelFacilitatorSigner, +} from "./signer"; + +/** + * How an Open channel becomes an abandon candidate. + * - `idle`: `expiresAt === 0` waits {@link DEFAULT_MAX_IDLE_SECS} (or the + * configured window) since the last facilitator-visible activity. Batch + * settlement. + * - `expiry`: every Open channel waits `expiresAt + grace`, so `expiresAt === 0` + * is already due. `upto`. + */ +export type OpenAbandonPolicy = "expiry" | "idle"; + +/** + * Stored facts the cleanup pass reads. `lastActivityAt` is only consulted by + * the `idle` policy; `upto` records omit it. + */ +export interface RentCleanupChannelRecord { + channelId: string; + payTo: string; + tokenProgram: string; + firstSeenAt: number; + expiresAt: number; + lastActivityAt?: number; + network: Network; +} + +/** Record discovery writes. `lastActivityAt` is always present. */ +export interface RentCleanupChannelWrite extends RentCleanupChannelRecord { + lastActivityAt: number; +} + +/** Storage the cleanup pass lists, updates, and deletes. */ +export interface RentCleanupChannelStorage { + get?(channelId: string): Promise; + list(): Promise; + upsert(record: RentCleanupChannelWrite): Promise; + delete(channelId: string): Promise; +} + +/** Reclaim work item: storage key plus live rent_payer from the channel account. */ +interface ReclaimCandidate { + channelId: string; + rentPayer: string; +} + +/** + * Ascending channel id, the scan's total order. + * + * @param a - First record + * @param b - Second record + * @returns Negative, zero, or positive per `Array.prototype.sort` + */ +function compareChannelId(a: RentCleanupChannelRecord, b: RentCleanupChannelRecord): number { + if (a.channelId < b.channelId) return -1; + if (a.channelId > b.channelId) return 1; + return 0; +} + +/** + * Put records in scan order, resuming where the previous pass stopped. + * + * `list()` promises no ordering, so the manager imposes + * one: without it the resume cursor would mean something different on every + * storage implementation, and a backlog larger than the budget could revisit + * the same records forever. Sorts by channel id, then rotates so `cursor` + * comes first. An unknown or empty cursor (a closed channel, or the first + * pass) scans from the beginning. + * + * @param records - Stored channel records in any order + * @param cursor - Channel id to resume from, or "" to scan from the start + * @returns Records sorted, then rotated so `cursor` (if present) comes first + */ +function orderForScan( + records: RentCleanupChannelRecord[], + cursor: string, +): RentCleanupChannelRecord[] { + const sorted = [...records].sort(compareChannelId); + if (!cursor) return sorted; + const index = sorted.findIndex(record => record.channelId === cursor); + if (index === -1) return sorted; + return [...sorted.slice(index), ...sorted.slice(0, index)]; +} + +/** + * Cancellation for one pass: the manager's own stop signal plus whatever the + * caller passed. Mirrors the Go SDK, where `Stop` cancels the context that + * `Cleanup` runs under. + */ +interface PassAbort { + /** Throws the abort reason once any signal has fired. */ + throwIfAborted(): void; + /** Reports whether any signal has fired. */ + aborted(): boolean; +} + +/** + * Combine the signals that can cancel a pass. + * + * @param signals - Manager and caller signals, either of which may be unset + * @returns Checks the pass calls at its cancellation points + */ +function passAbort(...signals: (AbortSignal | undefined)[]): PassAbort { + const active = signals.filter((signal): signal is AbortSignal => signal !== undefined); + return { + throwIfAborted: () => { + for (const signal of active) signal.throwIfAborted(); + }, + aborted: () => active.some(signal => signal.aborted), + }; +} + +/** + * Recognize the `AbortError` an aborted signal throws, so a requested stop is + * not reported to the operator as a cleanup failure. + * + * @param error - Error thrown by a pass + * @returns True when the pass ended because it was cancelled + */ +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +/** + * Resolve an operator-supplied numeric cleanup option. Non-positive, `NaN`, + * or non-finite values fall back to `defaultValue`, matching the Go SDK's + * `CleanupOptions.withDefaults`; a misconfigured `maxReclaimsPerTx` of `0` + * would otherwise spin the batching loop forever instead of budgeting a + * sane batch. The result is capped at `max` when given. + * + * @param value - Operator-supplied value, if any + * @param defaultValue - Value used when `value` is absent or invalid + * @param max - Optional upper bound the resolved value is clamped to + * @returns The resolved, positive option value + */ +function resolveCleanupCount( + value: number | undefined, + defaultValue: number, + max?: number, +): number { + const resolved = + value !== undefined && Number.isFinite(value) && value > 0 ? value : defaultValue; + return max !== undefined ? Math.min(resolved, max) : resolved; +} + +/** + * Whether an Open channel is ready to abandon-close. + * + * @param policy - Expiry grace (`upto`) or idle window (batch settlement) + * @param record - Stored channel facts + * @param nowSecs - Current unix time + * @param abandonGraceSecs - Grace added to `expiresAt` + * @param maxIdleSecs - Idle window for non-expiring vouchers; `0` disables it + * @returns Whether the channel should be abandon-closed on this pass + */ +function openChannelDue( + policy: OpenAbandonPolicy, + record: RentCleanupChannelRecord, + nowSecs: number, + abandonGraceSecs: number, + maxIdleSecs: number, +): boolean { + switch (policy) { + case "expiry": + return nowSecs >= record.expiresAt + abandonGraceSecs; + case "idle": { + if (record.expiresAt !== 0) return nowSecs >= record.expiresAt + abandonGraceSecs; + if (maxIdleSecs <= 0) return false; + const idleSinceSecs = Math.floor((record.lastActivityAt || record.firstSeenAt) / 1_000); + return nowSecs >= idleSinceSecs + maxIdleSecs; + } + default: { + const exhaustive: never = policy; + return exhaustive; + } + } +} + +/** Default grace after voucher expiry before abandon-closing an Open channel. */ +export const DEFAULT_ABANDON_GRACE_SECS = 120; + +/** + * Default idle window, in seconds, after which an Open channel with + * non-expiring vouchers (`expiresAt === 0`) and no facilitator-visible + * lifecycle activity is abandon-closed at its onchain settled watermark. + * Advertised to servers as `extra.maxIdleSecs`. Seven days. + */ +export const DEFAULT_MAX_IDLE_SECS = 7 * 24 * 60 * 60; + +/** + * Resolve an idle window: `undefined` or invalid means the default; `0` + * disables idle abandon-close entirely. + * + * @param value - Configured idle window in seconds + * @returns The idle window to enforce + */ +function resolveMaxIdleSecs(value: number | undefined): number { + return value !== undefined && Number.isFinite(value) && value >= 0 + ? Math.floor(value) + : DEFAULT_MAX_IDLE_SECS; +} + +/** + * Validate a configured idle window: a non-negative integer number of seconds + * (`0` disables), defaulting to {@link DEFAULT_MAX_IDLE_SECS} when absent. + * + * @param value - Configured idle window in seconds + * @returns The validated idle window + * @throws Error when the value is negative or not an integer + */ +export function assertMaxIdleSecs(value: number | undefined): number { + if (value === undefined) return DEFAULT_MAX_IDLE_SECS; + if (!Number.isInteger(value) || value < 0) { + throw new Error("maxIdleSecs must be a non-negative integer number of seconds"); + } + return value; +} + +/** Default reclaim instructions per cleanup transaction. */ +export const DEFAULT_MAX_RECLAIMS_PER_TX = 8; + +/** + * Largest reclaim batch proven, by the Go SDK's + * `TestReclaimBatchFitsInOneTransaction`, to serialize under Solana's + * `PACKET_DATA_SIZE` (1232 bytes) with every channel PDA distinct and one + * shared fee payer. `maxReclaimsPerTx` is clamped to this so a misconfigured + * operator value can never build a reclaim transaction that fails to + * serialize or gets rejected on broadcast. + */ +export const MAX_SAFE_RECLAIMS_PER_TX = 16; + +/** Default close/distribute transactions the storage scan may submit per call. */ +export const DEFAULT_MAX_TXS_PER_RUN = 20; + +/** Default reclaim transactions each rent payer may submit per call. */ +export const DEFAULT_MAX_TXS_PER_SIGNER = 20; + +/** Default abandon-close (settle+distribute) transactions per `cleanup` call. */ +export const DEFAULT_MAX_CLOSES_PER_RUN = 10; + +/** Result of a successful abandon-close or Sealed distribute. */ +export interface RentCleanupCloseResult { + channelId: string; + transaction: string; + action: "abandon_close" | "forced_close" | "distribute"; +} + +/** Result of a successful reclaim batch transaction. */ +export interface RentCleanupReclaimResult { + channelIds: string[]; + transaction: string; +} + +/** Options for one-shot and interval cleanup. */ +export interface RentCleanupOptions { + /** Seconds after `expiresAt` before abandon-close. Default 120. */ + abandonGraceSecs?: number; + /** + * Seconds without facilitator-visible lifecycle activity before an Open + * channel with non-expiring vouchers (`expiresAt === 0`) is abandon-closed + * at its onchain settled watermark. Defaults to the manager's configured + * window, then {@link DEFAULT_MAX_IDLE_SECS}; `0` disables. Should match + * what the facilitator advertises as `extra.maxIdleSecs`. + */ + maxIdleSecs?: number; + /** Max `reclaim` instructions packed into one transaction. */ + maxReclaimsPerTx?: number; + /** + * Transactions the storage scan may submit before it stops and saves a + * resume cursor. Caps the scan, not the pass: reclaims are budgeted + * separately by {@link maxTxsPerSigner}. + */ + maxTxsPerRun?: number; + /** + * Reclaim transactions each rent payer may submit per call. Budgeted per + * signer because rent-payer groups are independent: adding managed keys adds + * throughput rather than dividing a fixed pool. + */ + maxTxsPerSigner?: number; + /** Max abandon-close / Sealed-distribute transactions per call. */ + maxClosesPerRun?: number; + /** + * Cancels the pass at its next scan / discovery / reclaim checkpoint. The + * pass then rejects with an `AbortError`, matching the Go SDK's + * `Cleanup(ctx, …)` returning `ctx.Err()`. + */ + signal?: AbortSignal; + onClose?: (result: RentCleanupCloseResult) => void; + onReclaim?: (result: RentCleanupReclaimResult) => void; + onError?: (error: unknown, context?: { channelId?: string }) => void; +} + +/** Channels one discovery sweep added to storage. */ +export interface RentDiscoveryResult { + channelIds: string[]; +} + +/** Options for one-shot and interval discovery. */ +export interface RentDiscoveryOptions { + /** + * Cancels the sweep between managed signers. The sweep then rejects with an + * `AbortError`, matching the Go SDK's `Discover(ctx, …)` returning + * `ctx.Err()`. + */ + signal?: AbortSignal; + onDiscover?: (result: RentDiscoveryResult) => void; + onError?: (error: unknown, context?: { channelId?: string }) => void; +} + +/** Interval runner configuration. */ +export interface RentCleanupStartConfig extends RentCleanupOptions { + /** Seconds between `cleanup` ticks. Required to start the loop. */ + intervalSecs: number; + /** + * Seconds between {@link PaymentChannelRentCleanupManager.discover} sweeps. Omit to + * leave discovery off. A sweep is a `getProgramAccounts` scan per managed + * signer, so it belongs on a far longer interval than cleanup — daily is + * typical. + */ + discoveryIntervalSecs?: number; + onDiscover?: (result: RentDiscoveryResult) => void; +} + +export interface PaymentChannelRentCleanupManagerConfig { + signer: FacilitatorSvmSigner; + storage: RentCleanupChannelStorage; + network: Network; + /** + * `SetComputeUnitPrice` (microlamports per compute unit) attached to cleanup + * transactions; `0` omits the instruction. Defaults to + * `DEFAULT_COMPUTE_UNIT_PRICE_MICROLAMPORTS` (1). + */ + computeUnitPriceMicroLamports?: number; + /** + * `SetComputeUnitLimit` for close/distribute cleanup transactions. Defaults + * to `DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT` (100k, standard SPL Token + * settlement); raise it for compute-heavy Token-2022 extension mints. + * Reclaim batches instead derive their limit per channel + * (`reclaimComputeUnitLimit`) and are mint-independent. + */ + settleComputeUnitLimit?: number; + /** + * Default idle window for passes that do not set + * {@link RentCleanupOptions.maxIdleSecs}. Facilitator schemes pass the value + * they advertise so cleanup and advertisement cannot diverge. Used only by + * the `idle` abandon policy. + */ + maxIdleSecs?: number; + /** + * Open-channel abandon rule. Defaults to `idle` (batch settlement). `upto` + * passes `expiry`. + */ + abandonPolicy?: OpenAbandonPolicy; + /** + * When true (default), seal a Closing channel once its onchain grace + * elapses. `upto` passes false and leaves Closing channels alone. + */ + sealClosingChannels?: boolean; + /** + * Component name used in capability errors. Defaults to + * `PaymentChannelRentCleanupManager`. + */ + label?: string; +} + +/** + * Storage-driven rent cleanup worker for SVM payment channels. + * + * Operators opt in via {@link start} or an external cron calling + * {@link cleanup}; the facilitator scheme never auto-starts this. + */ +export class PaymentChannelRentCleanupManager { + private readonly signer: PaymentChannelFacilitatorSigner; + private readonly getKitSigner: (feePayer: Address) => FacilitatorSigningCapabilities; + private readonly storage: RentCleanupChannelStorage; + private readonly network: Network; + private readonly computeUnitPriceMicroLamports: number | undefined; + private readonly settleComputeUnitLimit: number | undefined; + private readonly maxIdleSecs: number | undefined; + private readonly abandonPolicy: OpenAbandonPolicy; + private readonly sealClosingChannels: boolean; + private readonly label: string; + + private timer: ReturnType | undefined; + private discoveryTimer: ReturnType | undefined; + private running = false; + private tickInFlight = false; + private discoveryTickInFlight = false; + private startConfig: RentCleanupStartConfig | undefined; + + /** + * Cancels passes the interval loop owns. Created by {@link start} and fired + * by {@link stop}, which then waits for the pass to unwind. + */ + private abortController: AbortController | undefined; + + /** + * Tail of the queued pass chain. Cleanup and discovery passes run one at a + * time so an operator's cron calling {@link cleanup} cannot race the + * interval loop into submitting the same close or reclaim twice, and so a + * discovery sweep cannot upsert a record cleanup is mid-way through + * deleting. + */ + private passQueue: Promise = Promise.resolve(); + + /** + * Resumes scanning where the previous pass's budget ran out, so a + * persistent close/reclaim backlog larger than maxTxsPerRun cannot starve + * records ordered later in storage.list() forever. Empty string starts + * from the beginning. Only ever read/written inside a queued pass. + */ + private scanCursor = ""; + + /** + * Create a rent cleanup manager for one network. + * + * @param config - Signer pool, channel storage, and network + */ + constructor(config: PaymentChannelRentCleanupManagerConfig) { + const label = config.label ?? "PaymentChannelRentCleanupManager"; + assertPaymentChannelFacilitatorSigner(config.signer, label); + this.getKitSigner = config.signer.getSigner.bind(config.signer); + this.signer = config.signer; + this.storage = config.storage; + this.network = config.network; + this.computeUnitPriceMicroLamports = config.computeUnitPriceMicroLamports; + this.settleComputeUnitLimit = config.settleComputeUnitLimit; + this.maxIdleSecs = config.maxIdleSecs; + this.abandonPolicy = config.abandonPolicy ?? "idle"; + this.sealClosingChannels = config.sealClosingChannels ?? true; + this.label = label; + } + + /** + * Clean up whatever is ready: abandon-close timed-out Open channels, + * finalize Closing channels once their onchain grace period elapses, + * distribute Sealed ones, and batch-reclaim Distributed channels past the + * open-slot gate. Defers too-early Closing and still-active Open channels. + * + * @param opts - Work caps and callbacks + * @returns A promise that resolves when this pass completes + */ + async cleanup(opts: RentCleanupOptions = {}): Promise { + return this.enqueue(() => this.runPass(opts)); + } + + /** + * Find Distributed channels this facilitator paid rent for that storage does + * not know about, and add them so {@link cleanup} reclaims them on a later + * pass. The spec §6 recovery path for a lost or incomplete work index. + * + * A sweep is a `getProgramAccounts` scan per managed signer key, which is + * far more expensive than a cleanup pass — run it rarely (see + * `discoveryIntervalSecs`), not on the cleanup interval. + * + * Discovered records carry only what the chain proves: `payTo`, + * `tokenProgram`, and `expiresAt` are empty. That is safe because only the + * Open and Sealed cleanup branches read them, and a Distributed channel + * never returns to those states. Channel ids already in storage are left + * alone so a full record is never overwritten with a partial one. + * + * @param opts - Cancellation and callbacks + * @returns A promise that resolves when this sweep completes + */ + async discover(opts: RentDiscoveryOptions = {}): Promise { + return this.enqueue(() => this.runDiscovery(opts)); + } + + /** + * Start the interval loops: {@link cleanup} always, {@link discover} only + * when `discoveryIntervalSecs` is set. + * + * @param config - Intervals and cleanup policy + */ + start(config: RentCleanupStartConfig): void { + if (this.running) return; + this.running = true; + this.startConfig = config; + this.abortController = new AbortController(); + this.timer = setInterval(() => { + void this.tick(); + }, config.intervalSecs * 1_000); + if (config.discoveryIntervalSecs !== undefined) { + this.discoveryTimer = setInterval(() => { + void this.discoveryTick(); + }, config.discoveryIntervalSecs * 1_000); + } + } + + /** + * Stop the interval loops and wait for the pass they left in flight. + * + * Cancels at the pass's next scan / discovery / reclaim checkpoint rather + * than mid-transaction, so a submitted settle is always awaited to its + * signature and its storage entry updated. Resolves once nothing is running, + * which makes it safe to await during shutdown. + * + * @returns A promise that resolves when the in-flight pass has unwound + */ + async stop(): Promise { + this.running = false; + if (this.timer !== undefined) { + clearInterval(this.timer); + this.timer = undefined; + } + if (this.discoveryTimer !== undefined) { + clearInterval(this.discoveryTimer); + this.discoveryTimer = undefined; + } + this.abortController?.abort(); + // The tail already swallows rejections; this only waits for it to settle. + await this.passQueue; + this.abortController = undefined; + this.startConfig = undefined; + } + + /** + * Queue a pass behind whatever is already running. + * + * @param pass - The pass to run once the queue drains + * @returns The pass's own promise, rejection included + */ + private enqueue(pass: () => Promise): Promise { + const queued = this.passQueue.then(pass); + // Swallow on the queue tail only: the caller still sees its own rejection. + this.passQueue = queued.catch(() => undefined); + return queued; + } + + /** + * Run one cleanup pass. Callers go through {@link cleanup}, which serializes + * passes. + * + * @param opts - Work caps and callbacks + */ + private async runPass(opts: RentCleanupOptions): Promise { + const abandonGraceSecs = resolveCleanupCount(opts.abandonGraceSecs, DEFAULT_ABANDON_GRACE_SECS); + const maxIdleSecs = resolveMaxIdleSecs(opts.maxIdleSecs ?? this.maxIdleSecs); + const maxReclaimsPerTx = resolveCleanupCount( + opts.maxReclaimsPerTx, + DEFAULT_MAX_RECLAIMS_PER_TX, + MAX_SAFE_RECLAIMS_PER_TX, + ); + const maxTxsPerRun = resolveCleanupCount(opts.maxTxsPerRun, DEFAULT_MAX_TXS_PER_RUN); + const maxTxsPerSigner = resolveCleanupCount(opts.maxTxsPerSigner, DEFAULT_MAX_TXS_PER_SIGNER); + const maxClosesPerRun = resolveCleanupCount(opts.maxClosesPerRun, DEFAULT_MAX_CLOSES_PER_RUN); + const abort = passAbort(this.abortController?.signal, opts.signal); + + const rpc = accountFetchRpc(this.signer, this.network); + const records = orderForScan(await this.storage.list(), this.scanCursor); + this.scanCursor = ""; + const nowSecs = Math.floor(Date.now() / 1_000); + let currentSlot: bigint | undefined; + let txsUsed = 0; + let closesUsed = 0; + const reclaimCandidates: ReclaimCandidate[] = []; + const getCurrentSlot = async (): Promise => { + currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); + return currentSlot; + }; + + for (const record of records) { + // Stop, not skip: the budget is spent, so nothing further in this pass + // can act on a record. Resume here next pass instead of always + // rescanning from the start. + if (txsUsed >= maxTxsPerRun) { + this.scanCursor = record.channelId; + break; + } + abort.throwIfAborted(); + if (record.network !== this.network) continue; + + try { + const maybe = await fetchMaybeChannel(rpc, address(record.channelId), { + commitment: STATE_COMMITMENT, + }); + if (!maybe.exists) { + await this.storage.delete(record.channelId); + continue; + } + + const live = maybe.data; + const status = live.status as ChannelStatus; + + if (status === ChannelStatus.Closing && !this.sealClosingChannels) { + continue; + } + + if ( + status === ChannelStatus.Open || + status === ChannelStatus.Closing || + status === ChannelStatus.Sealed + ) { + if (status === ChannelStatus.Open) { + if ( + !openChannelDue(this.abandonPolicy, record, nowSecs, abandonGraceSecs, maxIdleSecs) + ) { + continue; + } + } else if ( + status === ChannelStatus.Closing && + BigInt(nowSecs) < live.closureStartedAt + BigInt(live.gracePeriod) + ) { + continue; + } + // Skip, not stop: later records may be reclaimable, and reclaims are + // budgeted separately from closes. + if (closesUsed >= maxClosesPerRun) continue; + + if (!record.payTo) { + opts.onError?.(new Error(`channel ${record.channelId} missing payTo; skipping`), { + channelId: record.channelId, + }); + continue; + } + + const feePayer = live.payee; + const feePayerSigner = this.resolveFeePayer(feePayer); + if (!feePayerSigner) { + opts.onError?.( + new Error( + `channel ${record.channelId} feePayer ${feePayer} not in facilitator signer set`, + ), + { channelId: record.channelId }, + ); + continue; + } + + const signature = await this.submitCloseOrDistribute( + feePayerSigner, + record, + live, + status, + ); + closesUsed += 1; + txsUsed += 1; + opts.onClose?.({ + channelId: record.channelId, + transaction: signature, + action: + status === ChannelStatus.Open + ? "abandon_close" + : status === ChannelStatus.Closing + ? "forced_close" + : "distribute", + }); + await this.syncStorageAfterAction(record.channelId); + continue; + } + + if (status === ChannelStatus.Distributed) { + const slot = await getCurrentSlot(); + if (slot > live.openSlot + OPEN_SLOT_WINDOW) { + reclaimCandidates.push({ + channelId: record.channelId, + rentPayer: live.rentPayer, + }); + } + continue; + } + + // An unrecognized status has no cleanup path, and the record would + // otherwise sit in storage forever without the operator knowing. + opts.onError?.( + new Error(`channel ${record.channelId} has unrecognized status ${String(status)}`), + { channelId: record.channelId }, + ); + } catch (error) { + opts.onError?.(error, { channelId: record.channelId }); + } + } + + await this.submitReclaimBatches(reclaimCandidates, { + maxReclaimsPerTx, + maxTxsPerSigner, + abort, + onReclaim: opts.onReclaim, + onError: opts.onError, + }); + } + + /** + * Run one discovery sweep. Callers go through {@link discover}, which + * serializes it against cleanup passes. + * + * @param opts - Cancellation and callbacks + */ + private async runDiscovery(opts: RentDiscoveryOptions): Promise { + const abort = passAbort(this.abortController?.signal, opts.signal); + if (typeof this.signer.getProgramAccounts !== "function") { + throw new Error( + `${this.label}.discover requires getProgramAccounts on the signer. ` + + "Use toFacilitatorSvmSigner() which provides all required methods.", + ); + } + + const known = new Set((await this.storage.list()).map(record => record.channelId)); + const discovered: string[] = []; + let currentSlot: bigint | undefined; + + for (const managed of this.signer.getAddresses()) { + abort.throwIfAborted(); + let found; + try { + found = await discoverChannelsByRentPayer(this.signer, this.network, managed); + } catch (error) { + opts.onError?.(error); + continue; + } + currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); + + for (const { channelId, channel } of found) { + if (known.has(channelId)) continue; + known.add(channelId); + if (channel.status !== ChannelStatus.Distributed) continue; + if (currentSlot <= channel.openSlot + OPEN_SLOT_WINDOW) continue; + + try { + await this.storage.upsert({ + channelId, + payTo: "", + tokenProgram: "", + firstSeenAt: Date.now(), + expiresAt: 0, + lastActivityAt: Date.now(), + network: this.network, + }); + discovered.push(channelId); + } catch (error) { + opts.onError?.(error, { channelId }); + } + } + } + + if (discovered.length > 0) opts.onDiscover?.({ channelIds: discovered }); + } + + /** + * Interval tick. Skips while a tick is outstanding so a pass slower than the + * interval cannot pile up queued passes. + */ + private async tick(): Promise { + const config = this.startConfig; + if (!this.running || this.tickInFlight || !config) return; + this.tickInFlight = true; + try { + await this.cleanup(config); + } catch (error) { + // A stop() mid-pass is a requested shutdown, not a cleanup failure. + if (!isAbortError(error)) config.onError?.(error); + } finally { + this.tickInFlight = false; + } + } + + /** + * Discovery tick. Skips while a sweep is outstanding, so a sweep slower than + * its interval cannot pile up queued sweeps ahead of cleanup passes. + */ + private async discoveryTick(): Promise { + const config = this.startConfig; + if (!this.running || this.discoveryTickInFlight || !config) return; + this.discoveryTickInFlight = true; + try { + await this.discover(config); + } catch (error) { + if (!isAbortError(error)) config.onError?.(error); + } finally { + this.discoveryTickInFlight = false; + } + } + + /** + * Submit settle_and_seal(has_voucher=0)+distribute for Open, + * seal+distribute for Closing, or distribute alone for Sealed. + * + * @param feePayerSigner - Channel feePayer / payee signer + * @param record - Stored channel (must include payTo + tokenProgram) + * @param live - Refetched channel account + * @param status - Live status that selected this path + * @returns Broadcast signature + */ + private async submitCloseOrDistribute( + feePayerSigner: PaymentChannelSvmSigner, + record: RentCleanupChannelRecord, + live: Channel, + status: ChannelStatus, + ): Promise { + const splits = [{ bps: BASIS_POINTS_DENOMINATOR, recipient: record.payTo }]; + const distribute = await buildDistributeInstruction({ + channelId: record.channelId, + mint: live.mint, + network: this.network, + payee: live.payee, + payer: live.payer, + rentPayer: live.rentPayer, + splits, + tokenProgram: record.tokenProgram, + }); + + let instructions: ServerInstruction[]; + if (status === ChannelStatus.Open) { + instructions = [ + ...buildSettleAndSealInstructions({ + channelId: record.channelId, + payeeSigner: feePayerSigner, + }), + distribute, + ]; + } else if (status === ChannelStatus.Closing) { + instructions = [buildSealInstruction(record.channelId), distribute]; + } else { + instructions = [distribute]; + } + + return submitChannelTransactionWithSigner( + feePayerSigner, + this.signer, + this.network, + instructions, + { + computeUnitLimit: this.settleComputeUnitLimit, + computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, + }, + ); + } + + /** + * After a close/distribute, delete the storage entry if the PDA is gone. + * + * @param channelId - Channel PDA + */ + private async syncStorageAfterAction(channelId: string): Promise { + const maybe = await fetchMaybeChannel( + accountFetchRpc(this.signer, this.network), + address(channelId), + { commitment: STATE_COMMITMENT }, + ); + if (!maybe.exists) { + await this.storage.delete(channelId); + } + } + + /** + * Group reclaim candidates by rent_payer and run each group's batched + * reclaim transactions concurrently, each against its own maxTxsPerSigner + * budget. + * + * Submissions within a group stay sequential (each batch refetches live + * state, so a group depends on its own prior submissions to avoid + * double-reclaiming), but independent rent-payer groups do not share a + * budget or depend on one another: adding managed signer keys adds + * maxTxsPerSigner more reclaim throughput per pass, not a share of a fixed + * pool. + * + * @param candidates - Distributed channels ready to reclaim + * @param opts - Batch size, tx budget, callbacks + * @param opts.maxReclaimsPerTx - Max reclaim instructions per transaction + * @param opts.maxTxsPerSigner - Max reclaim transactions per rent-payer group + * @param opts.abort - Cancellation checked before each batch + * @param opts.onReclaim - Optional success callback per reclaim batch + * @param opts.onError - Optional error callback + */ + private async submitReclaimBatches( + candidates: ReclaimCandidate[], + opts: { + maxReclaimsPerTx: number; + maxTxsPerSigner: number; + abort: PassAbort; + onReclaim?: ((result: RentCleanupReclaimResult) => void) | undefined; + onError?: ((error: unknown, context?: { channelId?: string }) => void) | undefined; + }, + ): Promise { + if (opts.maxTxsPerSigner <= 0 || candidates.length === 0) return; + + const byRentPayer = new Map(); + for (const candidate of candidates) { + const group = byRentPayer.get(candidate.rentPayer) ?? []; + group.push(candidate); + byRentPayer.set(candidate.rentPayer, group); + } + + await Promise.all( + Array.from(byRentPayer.entries()).map(([rentPayer, group]) => + this.submitReclaimGroup(rentPayer, group, opts, { remaining: opts.maxTxsPerSigner }), + ), + ); + } + + /** + * Submit one rent payer's reclaim batches sequentially, claiming a slot + * from the shared budget before each attempt. + * + * @param rentPayer - Rent payer this group's channels share + * @param group - This rent payer's reclaim candidates + * @param opts - Batch size and callbacks + * @param opts.maxReclaimsPerTx - Max reclaim instructions per transaction + * @param opts.abort - Cancellation checked before each batch + * @param opts.onReclaim - Optional success callback per reclaim batch + * @param opts.onError - Optional error callback + * @param budget - This group's remaining-transaction counter + * @param budget.remaining - Reclaim transactions this rent payer may submit + */ + private async submitReclaimGroup( + rentPayer: string, + group: ReclaimCandidate[], + opts: { + maxReclaimsPerTx: number; + abort: PassAbort; + onReclaim?: ((result: RentCleanupReclaimResult) => void) | undefined; + onError?: ((error: unknown, context?: { channelId?: string }) => void) | undefined; + }, + budget: { remaining: number }, + ): Promise { + const feePayerSigner = this.resolveFeePayer(rentPayer); + if (!feePayerSigner) { + for (const candidate of group) { + opts.onError?.( + new Error( + `channel ${candidate.channelId} feePayer ${rentPayer} not in facilitator signer set`, + ), + { channelId: candidate.channelId }, + ); + } + return; + } + + for (let i = 0; i < group.length; i += opts.maxReclaimsPerTx) { + if (budget.remaining <= 0) return; + // Silent, unlike the scan loop: batches already submitted reported + // through onReclaim, and the pass as a whole reports the cancellation. + if (opts.abort.aborted()) return; + budget.remaining -= 1; + + const batch = group.slice(i, i + opts.maxReclaimsPerTx); + try { + // Refetch each account immediately before acting (stale → skip). + const rpc = accountFetchRpc(this.signer, this.network); + const liveBatch: ReclaimCandidate[] = []; + for (const candidate of batch) { + const maybe = await fetchMaybeChannel(rpc, address(candidate.channelId), { + commitment: STATE_COMMITMENT, + }); + if (!maybe.exists) { + await this.storage.delete(candidate.channelId); + continue; + } + if (maybe.data.status !== ChannelStatus.Distributed) continue; + liveBatch.push({ + channelId: candidate.channelId, + rentPayer: maybe.data.rentPayer, + }); + } + if (liveBatch.length === 0) continue; + + const instructions = liveBatch.map(candidate => + buildReclaimInstruction({ + channelId: candidate.channelId, + rentPayer: candidate.rentPayer, + }), + ); + const signature = await submitChannelTransactionWithSigner( + feePayerSigner, + this.signer, + this.network, + instructions, + { + computeUnitLimit: reclaimComputeUnitLimit(liveBatch.length), + computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, + }, + ); + opts.onReclaim?.({ + channelIds: liveBatch.map(c => c.channelId), + transaction: signature, + }); + for (const candidate of liveBatch) { + await this.storage.delete(candidate.channelId); + } + } catch (error) { + // Every channel in the batch is stuck, not just the first. + for (const candidate of batch) { + opts.onError?.(error, { channelId: candidate.channelId }); + } + } + } + } + + /** + * Resolve the facilitator signer for a channel payee / rent_payer. + * + * @param feePayerAddress - Live channel payee / rent_payer + * @returns Matching signer, or undefined when not configured + */ + private resolveFeePayer(feePayerAddress: string): PaymentChannelSvmSigner | undefined { + if (!this.signer.getAddresses().includes(feePayerAddress as Address)) { + return undefined; + } + return this.getKitSigner(feePayerAddress as Address); + } +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/requirements.ts b/typescript/packages/mechanisms/svm/src/payment-channels/requirements.ts new file mode 100644 index 0000000000..841c610658 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/requirements.ts @@ -0,0 +1,72 @@ +import type { PaymentRequirements } from "@x402/core/types"; + +import { TOKEN_2022_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS } from "../constants"; +import { getStablecoinTokenProgram, validateSvmAddress } from "../utils"; + +/** + * Read `extra.tokenProgram`. An absent hint is unset so the caller can fall back. + * A present hint that is not a supported program throws. + * + * @param extra - Payment requirements `extra` + * @returns The hinted program, or undefined when unset + */ +export function parseTokenProgramHint(extra: PaymentRequirements["extra"]): string | undefined { + const hint = extra?.tokenProgram; + if (hint === undefined || hint === null || hint === "") { + return undefined; + } + if (typeof hint !== "string" || !validateSvmAddress(hint)) { + throw new Error(`extra.tokenProgram ${String(hint)} is not a valid base58 address`); + } + if (hint !== TOKEN_PROGRAM_ADDRESS && hint !== TOKEN_2022_PROGRAM_ADDRESS) { + throw new Error(`extra.tokenProgram ${hint} is not a supported SPL token program`); + } + return hint; +} + +/** + * Hinted token program, or the stablecoin registry when the hint is absent. + * + * @param requirements - Payment requirements being paid + * @returns Token program address + */ +export function resolveTokenProgram(requirements: PaymentRequirements): string { + return ( + parseTokenProgramHint(requirements.extra) ?? + getStablecoinTokenProgram(requirements.asset, requirements.network) + ); +} + +/** + * Seller memo from `extra.memo`. Empty and non-string are unset, so both roles + * agree on whether a memo was requested. + * + * @param extra - Payment requirements `extra` + * @returns The requested memo, or undefined when unset + */ +export function resolveUptoSvmMemo(extra: PaymentRequirements["extra"]): string | undefined { + const memo = extra?.memo; + return typeof memo === "string" && memo !== "" ? memo : undefined; +} + +/** + * `parseTokenProgramHint`, with every missing or rejected hint thrown as `invalid`. + * Batch requires the hint; upto treats a missing hint as unset. + * + * @param extra - Payment requirements `extra` + * @param invalid - Error message for a missing or rejected hint + * @returns Supported token program address + */ +export function requireTokenProgramHint( + extra: PaymentRequirements["extra"], + invalid: string, +): string { + let hinted: string | undefined; + try { + hinted = parseTokenProgramHint(extra); + } catch { + hinted = undefined; + } + if (hinted === undefined) throw new Error(invalid); + return hinted; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/signer.ts b/typescript/packages/mechanisms/svm/src/payment-channels/signer.ts new file mode 100644 index 0000000000..e319ef3f41 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/signer.ts @@ -0,0 +1,140 @@ +import { address, type Address, type Signature } from "@solana/kit"; +import type { Network } from "@x402/core/types"; + +import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../signer"; +import { createRpcClient } from "../utils"; +import { fetchMaybeChannel } from "./generated/accounts/channel"; + +/** One signature touching an account, newest-first as returned by RPC. */ +export type ChannelAccountSignature = { + signature: string; + /** RPC error for a failed transaction, or null when it succeeded. */ + err: unknown; +}; + +/** + * {@link FacilitatorSvmSigner} narrowed to the caps payment-channel + * facilitator work requires: reading a channel, a slot, and a blockhash, and + * resolving a kit signer. Exact-only signers omit these methods, so they stay + * off the base type. `upto` and batch settlement both use this set. + * + * History reads are optional and are not provided by {@link toFacilitatorSvmSigner}. + * A batch facilitator with a receiver-authorizer store does not need them. + * {@link channelHistoryReads} serves them from an RPC. A null `getTransaction` + * means that open was pruned. + */ +export type PaymentChannelFacilitatorSigner = FacilitatorSvmSigner & { + getAccountInfo: NonNullable; + getLatestBlockhash: NonNullable; + getSlot: NonNullable; + getSigner(feePayer: Address): FacilitatorSigningCapabilities; + getSignaturesForAddress?( + accountAddress: string, + network: string, + options?: { before?: string; limit?: number }, + ): Promise; + /** Confirmed transaction wire bytes (base64), or null when unknown. */ + getTransaction?(signature: string, network: string): Promise; +}; + +const PAYMENT_CHANNEL_FACILITATOR_METHODS = [ + "getSigner", + "getAccountInfo", + "getLatestBlockhash", + "getSlot", +] as const satisfies readonly (keyof PaymentChannelFacilitatorSigner)[]; + +/** + * Assert a facilitator signer exposes every cap payment-channel facilitator + * work needs. + * + * @param signer - Facilitator signer to validate + * @param label - Component name for error messages + * @throws Error when a required capability is missing + */ +export function assertPaymentChannelFacilitatorSigner( + signer: FacilitatorSvmSigner, + label: string, +): asserts signer is PaymentChannelFacilitatorSigner { + for (const method of PAYMENT_CHANNEL_FACILITATOR_METHODS) { + if (typeof signer[method] !== "function") { + throw new Error(`${label} requires ${method} on the signer.`); + } + } +} + +/** + * Kit-compatible RPC adapter so generated account fetch helpers read through + * the facilitator signer. + * + * @param signer - Payment-channel facilitator signer + * @param network - CAIP-2 network identifier + * @returns Minimal RPC surface for {@link fetchMaybeChannel} + */ +export function accountFetchRpc( + signer: PaymentChannelFacilitatorSigner, + network: string, +): Parameters[0] { + return { + getAccountInfo: ( + accountAddress: Address, + config?: { commitment?: string; encoding?: string }, + ) => ({ + send: async () => ({ + context: { slot: 0n }, + value: await signer.getAccountInfo(accountAddress.toString(), network, { + commitment: config?.commitment, + encoding: config?.encoding, + }), + }), + }), + } as Parameters[0]; +} + +/** RPC that can page account signatures and return base64 transaction bytes. */ +export type ChannelHistoryReads = { + getSignaturesForAddress: NonNullable; + getTransaction: NonNullable; +}; + +/** + * Read confirmed transaction history from an RPC. + * + * A network omitted from `rpcUrlByNetwork` uses the public cluster default. + * That default does not promise to retain an open for the life of a channel. + * + * @param rpcUrlByNetwork - Optional full-history RPC URL per CAIP-2 network + * @returns History reads for a {@link PaymentChannelFacilitatorSigner} + */ +export function channelHistoryReads( + rpcUrlByNetwork: Partial> = {}, +): ChannelHistoryReads { + return { + async getSignaturesForAddress(accountAddress, network, options) { + const rpc = createRpcClient(network as Network, rpcUrlByNetwork[network]); + const config: { before?: Signature; limit?: number } = {}; + if (options?.before !== undefined) config.before = options.before as Signature; + if (options?.limit !== undefined) config.limit = options.limit; + const page = await rpc.getSignaturesForAddress(address(accountAddress), config).send(); + return page.map(item => ({ + err: item.err, + signature: String(item.signature), + })); + }, + async getTransaction(signature, network) { + const result = await createRpcClient(network as Network, rpcUrlByNetwork[network]) + .getTransaction(signature as Signature, { + commitment: "confirmed", + encoding: "base64", + maxSupportedTransactionVersion: 0, + }) + .send(); + if (!result) return null; + const transaction = (result as { transaction?: unknown }).transaction; + if (Array.isArray(transaction) && typeof transaction[0] === "string") { + return transaction[0]; + } + return null; + }, + }; +} diff --git a/typescript/packages/mechanisms/svm/src/payment-channels/storage.ts b/typescript/packages/mechanisms/svm/src/payment-channels/storage.ts new file mode 100644 index 0000000000..4657ce8850 --- /dev/null +++ b/typescript/packages/mechanisms/svm/src/payment-channels/storage.ts @@ -0,0 +1,100 @@ +import type { Network } from "@x402/core/types"; + +/** + * Stored payment-channel facts used by facilitator rent cleanup. + * + * Only stores what the channel account refetch does not provide: + * `payTo` (distribution preimage), `tokenProgram`, abandon-policy + * `firstSeenAt` and `lastActivityAt`, voucher `expiresAt`, and `network`. + * Payer/payee/mint/openSlot/status are read live before acting. + * + * Written on deposit (pre-broadcast), claim and distribution settles; deleted + * when the PDA is gone. + */ +export interface PaymentChannelRecord { + channelId: string; + /** Distribution recipient sealed at open (`requirements.payTo`). */ + payTo: string; + tokenProgram: string; + /** Wall-clock ms when the facilitator first stored this channel. */ + firstSeenAt: number; + /** Client voucher expiry (Unix seconds). Never shrinks on later upserts. */ + expiresAt: number; + /** + * Wall-clock ms of the last facilitator-visible lifecycle activity on the + * channel: an open, top-up, claim or distribution the facilitator + * processed. Offchain voucher acceptance on the server is invisible here. + * Drives the idle abandon-close of non-expiring (`expiresAt === 0`) + * channels after the advertised `maxIdleSecs`. Never moves backwards. + */ + lastActivityAt: number; + network: Network; +} + +/** Pluggable storage of channels the facilitator sponsors rent for. */ +export interface PaymentChannelStorage { + get(channelId: string): Promise; + /** + * Every stored record, in any order. The rent cleanup manager sorts by + * channel id before scanning, so implementations do not have to. + */ + list(): Promise; + upsert(record: PaymentChannelRecord): Promise; + delete(channelId: string): Promise; +} + +/** + * In-memory {@link PaymentChannelStorage}. Preserves `firstSeenAt` and the + * maximum `expiresAt` and `lastActivityAt` across upserts of the same + * `channelId`. + */ +export class InMemoryPaymentChannelStorage implements PaymentChannelStorage { + private readonly channels = new Map(); + + /** + * Look up a single stored channel. + * + * @param channelId - Channel PDA + * @returns Stored record, or undefined when absent + */ + async get(channelId: string): Promise { + return this.channels.get(channelId); + } + + /** + * List every stored channel record. + * + * @returns All stored channel records + */ + async list(): Promise { + return [...this.channels.values()]; + } + + /** + * Insert or replace a record. Keeps the earlier `firstSeenAt` and the + * later `expiresAt` and `lastActivityAt` when the channel was already + * stored. + * + * @param record - Full channel storage record + */ + async upsert(record: PaymentChannelRecord): Promise { + const existing = this.channels.get(record.channelId); + this.channels.set(record.channelId, { + ...record, + firstSeenAt: existing?.firstSeenAt ?? record.firstSeenAt, + expiresAt: existing ? Math.max(existing.expiresAt, record.expiresAt) : record.expiresAt, + lastActivityAt: existing + ? Math.max(existing.lastActivityAt, record.lastActivityAt) + : record.lastActivityAt, + }); + } + + /** + * Remove a channel from storage. + * + * @param channelId - Channel PDA to remove + */ + async delete(channelId: string): Promise { + this.channels.delete(channelId); + } +} diff --git a/typescript/packages/mechanisms/svm/src/signer.ts b/typescript/packages/mechanisms/svm/src/signer.ts index 4a4b2c02f7..bffd7f7b58 100644 --- a/typescript/packages/mechanisms/svm/src/signer.ts +++ b/typescript/packages/mechanisms/svm/src/signer.ts @@ -100,7 +100,10 @@ export type FacilitatorRpcCapabilities = { * @param signature - Transaction signature * @returns Confirmation result */ - confirmTransaction(signature: string): Promise; + confirmTransaction( + signature: string, + options?: { searchTransactionHistory?: boolean }, + ): Promise; /** * Fetch token mint information @@ -205,7 +208,11 @@ export type FacilitatorSvmSigner = { * @throws Error for any other confirmation failure, e.g. a wait timeout (non-terminal — * the outcome is unknown, so callers must not treat it as a definite failure) */ - confirmTransaction(signature: string, network: string): Promise; + confirmTransaction( + signature: string, + network: string, + options?: { searchTransactionHistory?: boolean }, + ): Promise<{ slot: bigint | number } | void>; /** * Simulate a transaction and return inner instructions (CPI calls). @@ -237,6 +244,12 @@ export type FacilitatorSvmSigner = { * @param network - CAIP-2 network identifier * @returns Inner instructions from the confirmed transaction, or null if not yet indexed */ + /** Confirmed transaction evidence used to attribute batch payouts. */ + getConfirmedTransaction?( + signature: string, + network: string, + ): Promise; + getConfirmedTransactionInnerInstructions?( signature: string, network: string, @@ -279,7 +292,7 @@ export type FacilitatorSvmSigner = { getAccountInfo?( accountAddress: string, network: string, - options?: { commitment?: string; encoding?: string }, + options?: { commitment?: string; encoding?: string; minContextSlot?: bigint }, ): Promise; /** @@ -297,6 +310,14 @@ export type FacilitatorSvmSigner = { */ getSlot?(network: string, commitment?: string): Promise; + /** + * Whether a blockhash can still serve as a transaction lifetime. Optional — + * lets batch recovery classify a broadcast that was never confirmed as + * expired once its blockhash has left the validity window; + * {@link toFacilitatorSvmSigner} provides an implementation. + */ + isBlockhashValid?(blockhash: string, network: string): Promise; + /** * Scan program accounts. Optional — required only for `upto` rent-cleanup * discovery sweeps; {@link toFacilitatorSvmSigner} provides an implementation. @@ -312,6 +333,25 @@ export type FacilitatorSvmSigner = { ): Promise; }; +/** Token balances from this transaction, never a later account snapshot. */ +export type FacilitatorTokenBalance = { + accountIndex: number; + mint: string; + owner?: string; + uiTokenAmount: { amount: string }; +}; + +/** Minimal transaction metadata needed by batch payout reconciliation. */ +export type FacilitatorConfirmedTransaction = { + slot: bigint | number; + meta: { + err: unknown; + preTokenBalances?: readonly FacilitatorTokenBalance[] | null; + postTokenBalances?: readonly FacilitatorTokenBalance[] | null; + } | null; + transaction: { message: { accountKeys: readonly (string | { pubkey: string })[] } }; +}; + /** Account info returned by {@link FacilitatorSvmSigner.getAccountInfo}. */ export type FacilitatorAccountInfo = { data: [string, string] | string; @@ -401,7 +441,7 @@ export function createRpcCapabilitiesFromRpc( }) .send(); }, - confirmTransaction: async signature => { + confirmTransaction: async (signature, options) => { // Poll at 250ms for the first ~2s (Solana slots are ~400ms), then 1s, // keeping the same ~30s confirmation budget as the previous 30×1s loop. const initialDelayMs = 250; @@ -409,9 +449,19 @@ export function createRpcCapabilitiesFromRpc( const fallbackDelayMs = 1_000; const maxWaitMs = 30_000; const startedAt = Date.now(); + let lookups = 0; while (Date.now() - startedAt < maxWaitMs) { - const status = await rpc.getSignatureStatuses([signature as never]).send(); + // Only the first lookup needs the history index: a transaction that + // landed before this poll started is found there once, and anything + // landing while we wait is in the recent-status cache anyway. + const searchHistory = options?.searchTransactionHistory === true && lookups === 0; + lookups += 1; + const status = await ( + searchHistory + ? rpc.getSignatureStatuses([signature as never], { searchTransactionHistory: true }) + : rpc.getSignatureStatuses([signature as never]) + ).send(); const entry = status.value[0]; if ( @@ -598,10 +648,12 @@ export function toFacilitatorSvmSigner( .send(); }, - confirmTransaction: async (signature: string, network: string) => { + confirmTransaction: async (signature: string, network: string, options) => { const rpc = getRpcForNetwork(network); const rpcCapabilities = createRpcCapabilitiesFromRpc(rpc); - await rpcCapabilities.confirmTransaction(signature); + return (await rpcCapabilities.confirmTransaction(signature, options)) as { + slot: bigint | number; + }; }, simulateTransactionWithInnerInstructions: async (transaction: string, network: string) => { @@ -654,6 +706,20 @@ export function toFacilitatorSvmSigner( return { innerInstructions: value.innerInstructions ?? null }; }, + getConfirmedTransaction: async (signature, network) => { + const result = await getRpcForNetwork(network) + .getTransaction( + signature as never, + { + commitment: "confirmed", + maxSupportedTransactionVersion: 0, + encoding: "jsonParsed", + } as never, + ) + .send(); + return result as unknown as FacilitatorConfirmedTransaction | null; + }, + getConfirmedTransactionInnerInstructions: async ( signature: string, network: string, @@ -734,6 +800,9 @@ export function toFacilitatorSvmSigner( .getAccountInfo(accountAddress as never, { commitment: (options?.commitment ?? "confirmed") as never, encoding: (options?.encoding ?? "base64") as never, + ...(options?.minContextSlot !== undefined + ? { minContextSlot: options.minContextSlot } + : {}), }) .send(); const value = result.value as FacilitatorAccountInfo | null; @@ -754,6 +823,14 @@ export function toFacilitatorSvmSigner( return await rpc.getSlot({ commitment: commitment as never }).send(); }, + isBlockhashValid: async (blockhash: string, network: string) => { + const rpc = getRpcForNetwork(network); + const result = await rpc + .isBlockhashValid(blockhash as never, { commitment: "confirmed" }) + .send(); + return result.value; + }, + getProgramAccounts: async (network, programId, config) => { const rpc = getRpcForNetwork(network); return await rpc diff --git a/typescript/packages/mechanisms/svm/src/upto/client/scheme.ts b/typescript/packages/mechanisms/svm/src/upto/client/scheme.ts index fc5af6f9fe..f1eeaa6da4 100644 --- a/typescript/packages/mechanisms/svm/src/upto/client/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/upto/client/scheme.ts @@ -5,14 +5,11 @@ import type { PaymentPayload, PaymentRequirements, SchemeNetworkClient } from "@ import { findDefaultAsset } from "../../defaultAssets"; import { buildOpenPaymentChannelTransaction } from "../../payment-channels/open"; +import { parseTokenProgramHint, resolveUptoSvmMemo } from "../../payment-channels/requirements"; import type { ClientSvmConfig, ClientSvmSigner } from "../../signer"; import { type UptoSvmPayloadV2 } from "../../types"; import { createRpcClient, resolveBlockhash, resolveOpenSlot } from "../../utils"; -import { - parseTokenProgramHint, - resolveUptoSvmMemo, - resolveUptoSvmPaymentChannelConfig, -} from "../shared"; +import { resolveUptoSvmPaymentChannelConfig } from "../shared"; /** * SVM client implementation for the `upto` payment scheme. diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts index d307bee443..bcf19e0c27 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/index.ts @@ -19,13 +19,13 @@ export type { } from "./scheme"; export { ErrSettlementPending } from "../../exact/facilitator/errors"; export { - ChannelOpenConfirmationError, + ChannelBroadcastConfirmationError as ChannelOpenConfirmationError, DEFAULT_CHANNEL_READ_BACKOFF_STEP_MS, DEFAULT_CHANNEL_READ_MAX_ATTEMPTS, + ChannelSimulationError as SettlementSimulationError, SettlementConfirmationTimeoutError, - SettlementSimulationError, -} from "./channel"; -export type { UptoSvmSigner } from "./channel"; +} from "../../payment-channels/facilitator"; +export type { PaymentChannelSvmSigner as UptoSvmSigner } from "../../payment-channels/facilitator"; export { InMemoryUptoChannelStorage } from "./channelStorage"; export type { UptoChannelRecord, UptoChannelStorage } from "./channelStorage"; export { diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts index 1e63641b3b..bbd3701e9a 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/rentCleanupManager.ts @@ -1,241 +1,36 @@ /** - * Facilitator-side async rent cleanup for SVM `upto` channels. - * - * Driven entirely by verify-time {@link UptoChannelStorage}: each pass lists - * stored channels, refetches live account status, then acts on whatever is - * ready (abandon-close / distribute / reclaim). RPC is not used for discovery. - * - * Signs with the live channel `payee` / `rent_payer` (same key in this scheme) - * from the facilitator's existing signer pool — no dedicated cleanup key. - * - * Policy note (spec §8): sealing abandoned Open channels before the server - * settles freezes the watermark and refunds the unsettled remainder to the - * client. Abandon timing uses `min(expiresAt + grace, firstSeenAt + max)` so - * normal vouchers expire cleanly while misconfigured long timeouts are capped. + * `upto` rent cleanup. The worker is {@link PaymentChannelRentCleanupManager}; + * this subclass fixes the scheme's abandon policy: Open channels close at + * `expiresAt + grace`, and Closing channels are left alone. */ -import { address, type Address, type Signature } from "@solana/kit"; import type { Network } from "@x402/core/types"; -import { discoverChannelsByRentPayer } from "../../payment-channels/discovery"; -import { fetchMaybeChannel, type Channel } from "../../payment-channels/generated/accounts/channel"; import { - buildDistributeInstruction, - buildReclaimInstruction, - buildSettleAndSealInstructions, - ChannelStatus, - type ServerInstruction, -} from "../../payment-channels/onchain"; -import { OPEN_SLOT_WINDOW } from "../../payment-channels/open"; -import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../../signer"; -import { BASIS_POINTS_DENOMINATOR, SLOT_COMMITMENT, STATE_COMMITMENT } from "../shared"; -import type { UptoSvmSigner } from "./channel"; -import { accountFetchRpc, reclaimComputeUnitLimit, submitSettle } from "./channel"; -import type { UptoChannelRecord, UptoChannelStorage } from "./channelStorage"; -import { assertUptoFacilitatorSigner, type UptoFacilitatorSigner } from "./signer"; - -/** Reclaim work item: storage key plus live rent_payer from the channel account. */ -interface ReclaimCandidate { - channelId: string; - rentPayer: string; -} - -/** - * Ascending channel id, the scan's total order. - * - * @param a - First record - * @param b - Second record - * @returns Negative, zero, or positive per `Array.prototype.sort` - */ -function compareChannelId(a: UptoChannelRecord, b: UptoChannelRecord): number { - if (a.channelId < b.channelId) return -1; - if (a.channelId > b.channelId) return 1; - return 0; -} - -/** - * Put records in scan order, resuming where the previous pass stopped. - * - * `UptoChannelStorage.list()` promises no ordering, so the manager imposes - * one: without it the resume cursor would mean something different on every - * storage implementation, and a backlog larger than the budget could revisit - * the same records forever. Sorts by channel id, then rotates so `cursor` - * comes first. An unknown or empty cursor (a closed channel, or the first - * pass) scans from the beginning. - * - * @param records - Stored channel records in any order - * @param cursor - Channel id to resume from, or "" to scan from the start - * @returns Records sorted, then rotated so `cursor` (if present) comes first - */ -function orderForScan(records: UptoChannelRecord[], cursor: string): UptoChannelRecord[] { - const sorted = [...records].sort(compareChannelId); - if (!cursor) return sorted; - const index = sorted.findIndex(record => record.channelId === cursor); - if (index === -1) return sorted; - return [...sorted.slice(index), ...sorted.slice(0, index)]; -} - -/** - * Cancellation for one pass: the manager's own stop signal plus whatever the - * caller passed. Mirrors the Go SDK, where `Stop` cancels the context that - * `Cleanup` runs under. - */ -interface PassAbort { - /** Throws the abort reason once any signal has fired. */ - throwIfAborted(): void; - /** Reports whether any signal has fired. */ - aborted(): boolean; -} - -/** - * Combine the signals that can cancel a pass. - * - * @param signals - Manager and caller signals, either of which may be unset - * @returns Checks the pass calls at its cancellation points - */ -function passAbort(...signals: (AbortSignal | undefined)[]): PassAbort { - const active = signals.filter((signal): signal is AbortSignal => signal !== undefined); - return { - throwIfAborted: () => { - for (const signal of active) signal.throwIfAborted(); - }, - aborted: () => active.some(signal => signal.aborted), - }; -} - -/** - * Recognize the `AbortError` an aborted signal throws, so a requested stop is - * not reported to the operator as a cleanup failure. - * - * @param error - Error thrown by a pass - * @returns True when the pass ended because it was cancelled - */ -function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === "AbortError"; -} - -/** - * Resolve an operator-supplied numeric cleanup option. Non-positive, `NaN`, - * or non-finite values fall back to `defaultValue`, matching the Go SDK's - * `CleanupOptions.withDefaults`; a misconfigured `maxReclaimsPerTx` of `0` - * would otherwise spin the batching loop forever instead of budgeting a - * sane batch. The result is capped at `max` when given. - * - * @param value - Operator-supplied value, if any - * @param defaultValue - Value used when `value` is absent or invalid - * @param max - Optional upper bound the resolved value is clamped to - * @returns The resolved, positive option value - */ -function resolveCleanupCount( - value: number | undefined, - defaultValue: number, - max?: number, -): number { - const resolved = - value !== undefined && Number.isFinite(value) && value > 0 ? value : defaultValue; - return max !== undefined ? Math.min(resolved, max) : resolved; -} - -/** Default grace after voucher expiry before abandon-closing an Open channel. */ -export const DEFAULT_ABANDON_GRACE_SECS = 120; - -/** Default reclaim instructions per cleanup transaction. */ -export const DEFAULT_MAX_RECLAIMS_PER_TX = 8; - -/** - * Largest reclaim batch proven, by the Go SDK's - * `TestReclaimBatchFitsInOneTransaction`, to serialize under Solana's - * `PACKET_DATA_SIZE` (1232 bytes) with every channel PDA distinct and one - * shared fee payer. `maxReclaimsPerTx` is clamped to this so a misconfigured - * operator value can never build a reclaim transaction that fails to - * serialize or gets rejected on broadcast. - */ -export const MAX_SAFE_RECLAIMS_PER_TX = 16; - -/** Default close/distribute transactions the storage scan may submit per call. */ -export const DEFAULT_MAX_TXS_PER_RUN = 20; - -/** Default reclaim transactions each rent payer may submit per call. */ -export const DEFAULT_MAX_TXS_PER_SIGNER = 20; - -/** Default abandon-close (settle+distribute) transactions per `cleanup` call. */ -export const DEFAULT_MAX_CLOSES_PER_RUN = 10; - -/** Result of a successful abandon-close or Sealed distribute. */ -export interface RentCleanupCloseResult { - channelId: string; - transaction: string; - action: "abandon_close" | "distribute"; -} - -/** Result of a successful reclaim batch transaction. */ -export interface RentCleanupReclaimResult { - channelIds: string[]; - transaction: string; -} - -/** Options for one-shot and interval cleanup. */ -export interface RentCleanupOptions { - /** Seconds after `expiresAt` before abandon-close. Default 120. */ - abandonGraceSecs?: number; - /** Max `reclaim` instructions packed into one transaction. */ - maxReclaimsPerTx?: number; - /** - * Transactions the storage scan may submit before it stops and saves a - * resume cursor. Caps the scan, not the pass: reclaims are budgeted - * separately by {@link maxTxsPerSigner}. - */ - maxTxsPerRun?: number; - /** - * Reclaim transactions each rent payer may submit per call. Budgeted per - * signer because rent-payer groups are independent: adding managed keys adds - * throughput rather than dividing a fixed pool. - */ - maxTxsPerSigner?: number; - /** Max abandon-close / Sealed-distribute transactions per call. */ - maxClosesPerRun?: number; - /** - * Cancels the pass at its next scan / discovery / reclaim checkpoint. The - * pass then rejects with an `AbortError`, matching the Go SDK's - * `Cleanup(ctx, …)` returning `ctx.Err()`. - */ - signal?: AbortSignal; - onClose?: (result: RentCleanupCloseResult) => void; - onReclaim?: (result: RentCleanupReclaimResult) => void; - onError?: (error: unknown, context?: { channelId?: string }) => void; -} - -/** Channels one discovery sweep added to storage. */ -export interface RentDiscoveryResult { - channelIds: string[]; -} - -/** Options for one-shot and interval discovery. */ -export interface RentDiscoveryOptions { - /** - * Cancels the sweep between managed signers. The sweep then rejects with an - * `AbortError`, matching the Go SDK's `Discover(ctx, …)` returning - * `ctx.Err()`. - */ - signal?: AbortSignal; - onDiscover?: (result: RentDiscoveryResult) => void; - onError?: (error: unknown, context?: { channelId?: string }) => void; -} - -/** Interval runner configuration. */ -export interface RentCleanupStartConfig extends RentCleanupOptions { - /** Seconds between `cleanup` ticks. Required to start the loop. */ - intervalSecs: number; - /** - * Seconds between {@link UptoSvmRentCleanupManager.discover} sweeps. Omit to - * leave discovery off. A sweep is a `getProgramAccounts` scan per managed - * signer, so it belongs on a far longer interval than cleanup — daily is - * typical. - */ - discoveryIntervalSecs?: number; - onDiscover?: (result: RentDiscoveryResult) => void; -} - + PaymentChannelRentCleanupManager, + type PaymentChannelRentCleanupManagerConfig, +} from "../../payment-channels/rentCleanup"; +import type { FacilitatorSvmSigner } from "../../signer"; +import type { UptoChannelStorage } from "./channelStorage"; + +export { + DEFAULT_ABANDON_GRACE_SECS, + DEFAULT_MAX_CLOSES_PER_RUN, + DEFAULT_MAX_RECLAIMS_PER_TX, + DEFAULT_MAX_TXS_PER_RUN, + DEFAULT_MAX_TXS_PER_SIGNER, + MAX_SAFE_RECLAIMS_PER_TX, +} from "../../payment-channels/rentCleanup"; +export type { + RentCleanupCloseResult, + RentCleanupOptions, + RentCleanupReclaimResult, + RentCleanupStartConfig, + RentDiscoveryOptions, + RentDiscoveryResult, +} from "../../payment-channels/rentCleanup"; + +/** Configuration for {@link UptoSvmRentCleanupManager}. */ export interface UptoSvmRentCleanupManagerConfig { signer: FacilitatorSvmSigner; storage: UptoChannelStorage; @@ -259,595 +54,23 @@ export interface UptoSvmRentCleanupManagerConfig { /** * Storage-driven rent cleanup worker for SVM `upto`. * - * Operators opt in via {@link start} or an external cron calling - * {@link cleanup}; the facilitator scheme never auto-starts this. + * Operators opt in via {@link PaymentChannelRentCleanupManager.start} or an + * external cron calling {@link PaymentChannelRentCleanupManager.cleanup}; the + * facilitator scheme never auto-starts this. */ -export class UptoSvmRentCleanupManager { - private readonly signer: UptoFacilitatorSigner; - private readonly getKitSigner: (feePayer: Address) => FacilitatorSigningCapabilities; - private readonly storage: UptoChannelStorage; - private readonly network: Network; - private readonly computeUnitPriceMicroLamports: number | undefined; - private readonly settleComputeUnitLimit: number | undefined; - - private timer: ReturnType | undefined; - private discoveryTimer: ReturnType | undefined; - private running = false; - private tickInFlight = false; - private discoveryTickInFlight = false; - private startConfig: RentCleanupStartConfig | undefined; - - /** - * Cancels passes the interval loop owns. Created by {@link start} and fired - * by {@link stop}, which then waits for the pass to unwind. - */ - private abortController: AbortController | undefined; - - /** - * Tail of the queued pass chain. Cleanup and discovery passes run one at a - * time so an operator's cron calling {@link cleanup} cannot race the - * interval loop into submitting the same close or reclaim twice, and so a - * discovery sweep cannot upsert a record cleanup is mid-way through - * deleting. - */ - private passQueue: Promise = Promise.resolve(); - - /** - * Resumes scanning where the previous pass's budget ran out, so a - * persistent close/reclaim backlog larger than maxTxsPerRun cannot starve - * records ordered later in storage.list() forever. Empty string starts - * from the beginning. Only ever read/written inside a queued pass. - */ - private scanCursor = ""; - +export class UptoSvmRentCleanupManager extends PaymentChannelRentCleanupManager { /** * Create a rent cleanup manager for one network. * - * @param config - Signer pool, channel storage, and network/RPC + * @param config - Signer pool, channel storage, and network */ constructor(config: UptoSvmRentCleanupManagerConfig) { - assertUptoFacilitatorSigner(config.signer, "UptoSvmRentCleanupManager"); - this.getKitSigner = config.signer.getSigner.bind(config.signer); - this.signer = config.signer; - this.storage = config.storage; - this.network = config.network; - this.computeUnitPriceMicroLamports = config.computeUnitPriceMicroLamports; - this.settleComputeUnitLimit = config.settleComputeUnitLimit; - } - - /** - * Clean up whatever is ready: abandon-close timed-out Open channels, - * distribute Sealed ones, batch-reclaim Distributed channels past the - * open-slot gate. Defers Closing / too-early / still-active Open. - * - * @param opts - Work caps and callbacks - * @returns A promise that resolves when this pass completes - */ - async cleanup(opts: RentCleanupOptions = {}): Promise { - return this.enqueue(() => this.runPass(opts)); - } - - /** - * Find Distributed channels this facilitator paid rent for that storage does - * not know about, and add them so {@link cleanup} reclaims them on a later - * pass. The spec §6 recovery path for a lost or incomplete work index. - * - * A sweep is a `getProgramAccounts` scan per managed signer key, which is - * far more expensive than a cleanup pass — run it rarely (see - * `discoveryIntervalSecs`), not on the cleanup interval. - * - * Discovered records carry only what the chain proves: `payTo`, - * `tokenProgram`, and `expiresAt` are empty. That is safe because only the - * Open and Sealed cleanup branches read them, and a Distributed channel - * never returns to those states. Channel ids already in storage are left - * alone so a full record is never overwritten with a partial one. - * - * @param opts - Cancellation and callbacks - * @returns A promise that resolves when this sweep completes - */ - async discover(opts: RentDiscoveryOptions = {}): Promise { - return this.enqueue(() => this.runDiscovery(opts)); - } - - /** - * Start the interval loops: {@link cleanup} always, {@link discover} only - * when `discoveryIntervalSecs` is set. - * - * @param config - Intervals and cleanup policy - */ - start(config: RentCleanupStartConfig): void { - if (this.running) return; - this.running = true; - this.startConfig = config; - this.abortController = new AbortController(); - this.timer = setInterval(() => { - void this.tick(); - }, config.intervalSecs * 1_000); - if (config.discoveryIntervalSecs !== undefined) { - this.discoveryTimer = setInterval(() => { - void this.discoveryTick(); - }, config.discoveryIntervalSecs * 1_000); - } - } - - /** - * Stop the interval loops and wait for the pass they left in flight. - * - * Cancels at the pass's next scan / discovery / reclaim checkpoint rather - * than mid-transaction, so a submitted settle is always awaited to its - * signature and its storage entry updated. Resolves once nothing is running, - * which makes it safe to await during shutdown. - * - * @returns A promise that resolves when the in-flight pass has unwound - */ - async stop(): Promise { - this.running = false; - if (this.timer !== undefined) { - clearInterval(this.timer); - this.timer = undefined; - } - if (this.discoveryTimer !== undefined) { - clearInterval(this.discoveryTimer); - this.discoveryTimer = undefined; - } - this.abortController?.abort(); - // The tail already swallows rejections; this only waits for it to settle. - await this.passQueue; - this.abortController = undefined; - this.startConfig = undefined; - } - - /** - * Queue a pass behind whatever is already running. - * - * @param pass - The pass to run once the queue drains - * @returns The pass's own promise, rejection included - */ - private enqueue(pass: () => Promise): Promise { - const queued = this.passQueue.then(pass); - // Swallow on the queue tail only: the caller still sees its own rejection. - this.passQueue = queued.catch(() => undefined); - return queued; - } - - /** - * Run one cleanup pass. Callers go through {@link cleanup}, which serializes - * passes. - * - * @param opts - Work caps and callbacks - */ - private async runPass(opts: RentCleanupOptions): Promise { - const abandonGraceSecs = resolveCleanupCount(opts.abandonGraceSecs, DEFAULT_ABANDON_GRACE_SECS); - const maxReclaimsPerTx = resolveCleanupCount( - opts.maxReclaimsPerTx, - DEFAULT_MAX_RECLAIMS_PER_TX, - MAX_SAFE_RECLAIMS_PER_TX, - ); - const maxTxsPerRun = resolveCleanupCount(opts.maxTxsPerRun, DEFAULT_MAX_TXS_PER_RUN); - const maxTxsPerSigner = resolveCleanupCount(opts.maxTxsPerSigner, DEFAULT_MAX_TXS_PER_SIGNER); - const maxClosesPerRun = resolveCleanupCount(opts.maxClosesPerRun, DEFAULT_MAX_CLOSES_PER_RUN); - const abort = passAbort(this.abortController?.signal, opts.signal); - - const rpc = accountFetchRpc(this.signer, this.network); - const records = orderForScan(await this.storage.list(), this.scanCursor); - this.scanCursor = ""; - const nowSecs = Math.floor(Date.now() / 1_000); - let currentSlot: bigint | undefined; - let txsUsed = 0; - let closesUsed = 0; - const reclaimCandidates: ReclaimCandidate[] = []; - const getCurrentSlot = async (): Promise => { - currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); - return currentSlot; + const shared: PaymentChannelRentCleanupManagerConfig = { + ...config, + abandonPolicy: "expiry", + label: "UptoSvmRentCleanupManager", + sealClosingChannels: false, }; - - for (const record of records) { - // Stop, not skip: the budget is spent, so nothing further in this pass - // can act on a record. Resume here next pass instead of always - // rescanning from the start. - if (txsUsed >= maxTxsPerRun) { - this.scanCursor = record.channelId; - break; - } - abort.throwIfAborted(); - if (record.network !== this.network) continue; - - try { - const maybe = await fetchMaybeChannel(rpc, address(record.channelId), { - commitment: STATE_COMMITMENT, - }); - if (!maybe.exists) { - await this.storage.delete(record.channelId); - continue; - } - - const live = maybe.data; - const status = live.status as ChannelStatus; - - if (status === ChannelStatus.Closing) { - continue; - } - - if (status === ChannelStatus.Open || status === ChannelStatus.Sealed) { - if (status === ChannelStatus.Open) { - const readyAt = record.expiresAt + abandonGraceSecs; - if (nowSecs < readyAt) continue; - } - // Skip, not stop: later records may be reclaimable, and reclaims are - // budgeted separately from closes. - if (closesUsed >= maxClosesPerRun) continue; - - if (!record.payTo) { - opts.onError?.(new Error(`channel ${record.channelId} missing payTo; skipping`), { - channelId: record.channelId, - }); - continue; - } - - const feePayer = live.payee; - const feePayerSigner = this.resolveFeePayer(feePayer); - if (!feePayerSigner) { - opts.onError?.( - new Error( - `channel ${record.channelId} feePayer ${feePayer} not in facilitator signer set`, - ), - { channelId: record.channelId }, - ); - continue; - } - - const signature = await this.submitCloseOrDistribute( - feePayerSigner, - record, - live, - status, - ); - closesUsed += 1; - txsUsed += 1; - opts.onClose?.({ - channelId: record.channelId, - transaction: signature, - action: status === ChannelStatus.Open ? "abandon_close" : "distribute", - }); - await this.syncStorageAfterAction(record.channelId); - continue; - } - - if (status === ChannelStatus.Distributed) { - const slot = await getCurrentSlot(); - if (slot > live.openSlot + OPEN_SLOT_WINDOW) { - reclaimCandidates.push({ - channelId: record.channelId, - rentPayer: live.rentPayer, - }); - } - continue; - } - - // An unrecognized status has no cleanup path, and the record would - // otherwise sit in storage forever without the operator knowing. - opts.onError?.( - new Error(`channel ${record.channelId} has unrecognized status ${String(status)}`), - { channelId: record.channelId }, - ); - } catch (error) { - opts.onError?.(error, { channelId: record.channelId }); - } - } - - await this.submitReclaimBatches(reclaimCandidates, { - maxReclaimsPerTx, - maxTxsPerSigner, - abort, - onReclaim: opts.onReclaim, - onError: opts.onError, - }); - } - - /** - * Run one discovery sweep. Callers go through {@link discover}, which - * serializes it against cleanup passes. - * - * @param opts - Cancellation and callbacks - */ - private async runDiscovery(opts: RentDiscoveryOptions): Promise { - const abort = passAbort(this.abortController?.signal, opts.signal); - if (typeof this.signer.getProgramAccounts !== "function") { - throw new Error( - "UptoSvmRentCleanupManager.discover requires getProgramAccounts on the signer. " + - "Use toFacilitatorSvmSigner() which provides all required methods.", - ); - } - - const known = new Set((await this.storage.list()).map(record => record.channelId)); - const discovered: string[] = []; - let currentSlot: bigint | undefined; - - for (const managed of this.signer.getAddresses()) { - abort.throwIfAborted(); - let found; - try { - found = await discoverChannelsByRentPayer(this.signer, this.network, managed); - } catch (error) { - opts.onError?.(error); - continue; - } - currentSlot ??= await this.signer.getSlot(this.network, SLOT_COMMITMENT); - - for (const { channelId, channel } of found) { - if (known.has(channelId)) continue; - known.add(channelId); - if (channel.status !== ChannelStatus.Distributed) continue; - if (currentSlot <= channel.openSlot + OPEN_SLOT_WINDOW) continue; - - try { - await this.storage.upsert({ - channelId, - payTo: "", - tokenProgram: "", - firstSeenAt: Date.now(), - expiresAt: 0, - network: this.network, - }); - discovered.push(channelId); - } catch (error) { - opts.onError?.(error, { channelId }); - } - } - } - - if (discovered.length > 0) opts.onDiscover?.({ channelIds: discovered }); - } - - /** - * Interval tick. Skips while a tick is outstanding so a pass slower than the - * interval cannot pile up queued passes. - */ - private async tick(): Promise { - const config = this.startConfig; - if (!this.running || this.tickInFlight || !config) return; - this.tickInFlight = true; - try { - await this.cleanup(config); - } catch (error) { - // A stop() mid-pass is a requested shutdown, not a cleanup failure. - if (!isAbortError(error)) config.onError?.(error); - } finally { - this.tickInFlight = false; - } - } - - /** - * Discovery tick. Skips while a sweep is outstanding, so a sweep slower than - * its interval cannot pile up queued sweeps ahead of cleanup passes. - */ - private async discoveryTick(): Promise { - const config = this.startConfig; - if (!this.running || this.discoveryTickInFlight || !config) return; - this.discoveryTickInFlight = true; - try { - await this.discover(config); - } catch (error) { - if (!isAbortError(error)) config.onError?.(error); - } finally { - this.discoveryTickInFlight = false; - } - } - - /** - * Submit settle_and_seal(has_voucher=0)+distribute for Open, or distribute - * alone for Sealed. - * - * @param feePayerSigner - Channel feePayer / payee signer - * @param record - Stored channel (must include payTo + tokenProgram) - * @param live - Refetched channel account - * @param status - Live status that selected this path - * @returns Broadcast signature - */ - private async submitCloseOrDistribute( - feePayerSigner: UptoSvmSigner, - record: UptoChannelRecord, - live: Channel, - status: ChannelStatus, - ): Promise { - const splits = [{ bps: BASIS_POINTS_DENOMINATOR, recipient: record.payTo }]; - const distribute = await buildDistributeInstruction({ - channelId: record.channelId, - mint: live.mint, - network: this.network, - payee: live.payee, - payer: live.payer, - rentPayer: live.rentPayer, - splits, - tokenProgram: record.tokenProgram, - }); - - const instructions: ServerInstruction[] = - status === ChannelStatus.Open - ? [ - ...buildSettleAndSealInstructions({ - channelId: record.channelId, - payeeSigner: feePayerSigner, - }), - distribute, - ] - : [distribute]; - - return submitSettle(feePayerSigner, this.signer, this.network, instructions, { - computeUnitLimit: this.settleComputeUnitLimit, - computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, - }); - } - - /** - * After a close/distribute, delete the storage entry if the PDA is gone. - * - * @param channelId - Channel PDA - */ - private async syncStorageAfterAction(channelId: string): Promise { - const rpc = accountFetchRpc(this.signer, this.network); - const maybe = await fetchMaybeChannel(rpc, address(channelId), { - commitment: STATE_COMMITMENT, - }); - if (!maybe.exists) { - await this.storage.delete(channelId); - } - } - - /** - * Group reclaim candidates by rent_payer and run each group's batched - * reclaim transactions concurrently, each against its own maxTxsPerSigner - * budget. - * - * Submissions within a group stay sequential (each batch refetches live - * state, so a group depends on its own prior submissions to avoid - * double-reclaiming), but independent rent-payer groups do not share a - * budget or depend on one another: adding managed signer keys adds - * maxTxsPerSigner more reclaim throughput per pass, not a share of a fixed - * pool. - * - * @param candidates - Distributed channels ready to reclaim - * @param opts - Batch size, tx budget, callbacks - * @param opts.maxReclaimsPerTx - Max reclaim instructions per transaction - * @param opts.maxTxsPerSigner - Max reclaim transactions per rent-payer group - * @param opts.abort - Cancellation checked before each batch - * @param opts.onReclaim - Optional success callback per reclaim batch - * @param opts.onError - Optional error callback - */ - private async submitReclaimBatches( - candidates: ReclaimCandidate[], - opts: { - maxReclaimsPerTx: number; - maxTxsPerSigner: number; - abort: PassAbort; - onReclaim?: ((result: RentCleanupReclaimResult) => void) | undefined; - onError?: ((error: unknown, context?: { channelId?: string }) => void) | undefined; - }, - ): Promise { - if (opts.maxTxsPerSigner <= 0 || candidates.length === 0) return; - - const byRentPayer = new Map(); - for (const candidate of candidates) { - const group = byRentPayer.get(candidate.rentPayer) ?? []; - group.push(candidate); - byRentPayer.set(candidate.rentPayer, group); - } - - await Promise.all( - Array.from(byRentPayer.entries()).map(([rentPayer, group]) => - this.submitReclaimGroup(rentPayer, group, opts, { remaining: opts.maxTxsPerSigner }), - ), - ); - } - - /** - * Submit one rent payer's reclaim batches sequentially, claiming a slot - * from the shared budget before each attempt. - * - * @param rentPayer - Rent payer this group's channels share - * @param group - This rent payer's reclaim candidates - * @param opts - Batch size and callbacks - * @param opts.maxReclaimsPerTx - Max reclaim instructions per transaction - * @param opts.abort - Cancellation checked before each batch - * @param opts.onReclaim - Optional success callback per reclaim batch - * @param opts.onError - Optional error callback - * @param budget - This group's remaining-transaction counter - * @param budget.remaining - Reclaim transactions this rent payer may submit - */ - private async submitReclaimGroup( - rentPayer: string, - group: ReclaimCandidate[], - opts: { - maxReclaimsPerTx: number; - abort: PassAbort; - onReclaim?: ((result: RentCleanupReclaimResult) => void) | undefined; - onError?: ((error: unknown, context?: { channelId?: string }) => void) | undefined; - }, - budget: { remaining: number }, - ): Promise { - const feePayerSigner = this.resolveFeePayer(rentPayer); - if (!feePayerSigner) { - for (const candidate of group) { - opts.onError?.( - new Error( - `channel ${candidate.channelId} feePayer ${rentPayer} not in facilitator signer set`, - ), - { channelId: candidate.channelId }, - ); - } - return; - } - - for (let i = 0; i < group.length; i += opts.maxReclaimsPerTx) { - if (budget.remaining <= 0) return; - // Silent, unlike the scan loop: batches already submitted reported - // through onReclaim, and the pass as a whole reports the cancellation. - if (opts.abort.aborted()) return; - budget.remaining -= 1; - - const batch = group.slice(i, i + opts.maxReclaimsPerTx); - try { - const rpc = accountFetchRpc(this.signer, this.network); - // Refetch each account immediately before acting (stale → skip). - const liveBatch: ReclaimCandidate[] = []; - for (const candidate of batch) { - const maybe = await fetchMaybeChannel(rpc, address(candidate.channelId), { - commitment: STATE_COMMITMENT, - }); - if (!maybe.exists) { - await this.storage.delete(candidate.channelId); - continue; - } - if (maybe.data.status !== ChannelStatus.Distributed) continue; - liveBatch.push({ - channelId: candidate.channelId, - rentPayer: maybe.data.rentPayer, - }); - } - if (liveBatch.length === 0) continue; - - const instructions = liveBatch.map(candidate => - buildReclaimInstruction({ - channelId: candidate.channelId, - rentPayer: candidate.rentPayer, - }), - ); - const signature = await submitSettle( - feePayerSigner, - this.signer, - this.network, - instructions, - { - computeUnitLimit: reclaimComputeUnitLimit(liveBatch.length), - computeUnitPriceMicroLamports: this.computeUnitPriceMicroLamports, - }, - ); - opts.onReclaim?.({ - channelIds: liveBatch.map(c => c.channelId), - transaction: signature, - }); - for (const candidate of liveBatch) { - await this.storage.delete(candidate.channelId); - } - } catch (error) { - // Every channel in the batch is stuck, not just the first. - for (const candidate of batch) { - opts.onError?.(error, { channelId: candidate.channelId }); - } - } - } - } - - /** - * Resolve the facilitator signer for a channel payee / rent_payer. - * - * @param feePayerAddress - Live channel payee / rent_payer - * @returns Matching signer, or undefined when not configured - */ - private resolveFeePayer(feePayerAddress: string): UptoSvmSigner | undefined { - if (!this.signer.getAddresses().includes(feePayerAddress as Address)) { - return undefined; - } - return this.getKitSigner(feePayerAddress as Address); + super(shared); } } diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts index 4eb50ac638..6ed7873aa6 100644 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/svm/src/upto/facilitator/scheme.ts @@ -13,12 +13,32 @@ import { type PendingSettlementStore, } from "@x402/core/facilitator"; +import { SLOT_COMMITMENT } from "../../payment-channels/commitments"; import { buildDistributeInstruction, buildSettleAndSealInstructions, type ServerInstruction, } from "../../payment-channels/onchain"; import { parseU64, verifyOpenTransaction } from "../../payment-channels/open"; +import { resolveTokenProgram, resolveUptoSvmMemo } from "../../payment-channels/requirements"; +import { + accountFetchRpc, + assertPaymentChannelFacilitatorSigner, + type PaymentChannelFacilitatorSigner, +} from "../../payment-channels/signer"; +import { + broadcastOpen, + channelExists, + ChannelBroadcastConfirmationError, + ChannelSimulationError, + fetchAndVerifyOpenChannel, + SettlementConfirmationTimeoutError, + simulateOpenSettleDistribute, + submitChannelTransactionWithSigner, + type ChannelReadPolicy, + type ChannelRpc, + type PaymentChannelSvmSigner, +} from "../../payment-channels/facilitator"; import { encodeVoucherMessageBytes, signVoucher, @@ -35,32 +55,13 @@ import { validateSvmAddress, } from "../../utils"; import { ErrSettlementPending } from "../../exact/facilitator/errors"; -import { - resolveTokenProgram, - resolveUptoSvmMemo, - resolveUptoSvmPaymentChannelConfig, - SLOT_COMMITMENT, - type UptoSvmPaymentChannelConfig, -} from "../shared"; -import { - broadcastOpen, - channelExists, - ChannelOpenConfirmationError, - fetchAndVerifyOpenChannel, - SettlementConfirmationTimeoutError, - SettlementSimulationError, - simulateOpenSettleDistribute, - submitSettle, - type ChannelReadPolicy, - type UptoSvmSigner, -} from "./channel"; +import { resolveUptoSvmPaymentChannelConfig, type UptoSvmPaymentChannelConfig } from "../shared"; import { InMemoryUptoChannelStorage, type UptoChannelRecord, type UptoChannelStorage, } from "./channelStorage"; import { InMemoryUptoDelegatedAuthStore, type UptoDelegatedAuthStore } from "./delegatedAuthStore"; -import { assertUptoFacilitatorSigner, type UptoFacilitatorSigner } from "./signer"; import { UptoSvmRentCleanupManager } from "./rentCleanupManager"; /** Scheme-specific error returned when the settlement amount exceeds the ceiling. */ @@ -261,7 +262,7 @@ type OpenAuthFailure = { type OpenAuthContext = { p: UptoSvmPayloadV2; channelConfig: UptoSvmPaymentChannelConfig; - feePayerSigner: UptoSvmSigner; + feePayerSigner: PaymentChannelSvmSigner; maxAmount: bigint; tokenProgram: string; }; @@ -296,7 +297,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { private readonly authorizerSigner: MessagePartialSigner | undefined; private readonly resolveCallerIdentity: UptoSvmFacilitatorConfig["resolveCallerIdentity"]; private readonly delegatedAuthStore: UptoDelegatedAuthStore; - private readonly signer: UptoFacilitatorSigner; + private readonly signer: PaymentChannelFacilitatorSigner; private readonly getKitSigner: (feePayer: Address) => FacilitatorSigningCapabilities; @@ -317,7 +318,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { assertLimit("maxRequiredSignatures", config.maxRequiredSignatures, 1); assertLimit("computeUnitPriceMicroLamports", config.computeUnitPriceMicroLamports, 0); assertLimit("settleComputeUnitLimit", config.settleComputeUnitLimit, 1); - assertUptoFacilitatorSigner(signer); + assertPaymentChannelFacilitatorSigner(signer, "UptoSvmScheme"); this.signer = signer; this.getKitSigner = signer.getSigner.bind(signer); if (this.signer.getAddresses().length === 0) { @@ -357,9 +358,9 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { network: Network, options?: { signer?: FacilitatorSvmSigner }, ): UptoSvmRentCleanupManager { - let cleanupSigner: UptoFacilitatorSigner = this.signer; + let cleanupSigner: PaymentChannelFacilitatorSigner = this.signer; if (options?.signer) { - assertUptoFacilitatorSigner(options.signer, "UptoSvmRentCleanupManager"); + assertPaymentChannelFacilitatorSigner(options.signer, "UptoSvmRentCleanupManager"); cleanupSigner = options.signer; } return new UptoSvmRentCleanupManager({ @@ -678,7 +679,8 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { // One authorization → one deposit open. A confirmed channel is replay or a // stranded prior open, not a supported re-bind path; handler failure after // a successful deposit uses the zero-amount cancel/refund settle instead. - if (await channelExists(this.signer, network, p.channelId)) { + const channelRpc = accountFetchRpc(this.signer, network) as ChannelRpc; + if (await channelExists(channelRpc, p.channelId)) { return this.settleFailure(payload, ERR_CHANNEL_ALREADY_OPEN, p.from); } @@ -771,7 +773,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { // lock in place (a fresh broadcast would double-open) and record the // signature so a retry reconciles via the fast path above instead of // re-validating. - if (error instanceof ChannelOpenConfirmationError) { + if (error instanceof ChannelBroadcastConfirmationError) { return recordPendingOrTerminal( this.pendingStore, depositKey, @@ -796,8 +798,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { try { await fetchAndVerifyOpenChannel( - this.signer, - network, + channelRpc, p.channelId, { authorizedSigner: channelConfig.receiverAuthorizer, @@ -971,8 +972,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { const network = requirements.network; const channelPromise = fetchAndVerifyOpenChannel( - this.signer, - network, + accountFetchRpc(this.signer, network) as ChannelRpc, p.channelId, { authorizedSigner: channelConfig.receiverAuthorizer, @@ -1053,11 +1053,17 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { }); const instructions: ServerInstruction[] = [...settle, distribute]; - const signature = await submitSettle(feePayerSigner, this.signer, network, instructions, { - computeUnitLimit: this.config.settleComputeUnitLimit, - computeUnitPriceMicroLamports: this.config.computeUnitPriceMicroLamports, - latestBlockhash: prefetchedBlockhash, - }); + const signature = await submitChannelTransactionWithSigner( + feePayerSigner, + this.signer, + network, + instructions, + { + computeUnitLimit: this.config.settleComputeUnitLimit, + computeUnitPriceMicroLamports: this.config.computeUnitPriceMicroLamports, + latestBlockhash: prefetchedBlockhash, + }, + ); // Settlement is confirmed onchain past this point; storage is cleanup // bookkeeping and must never turn a charged payment into a failure. @@ -1088,7 +1094,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { payer: channel.payer, }; } catch (error) { - if (error instanceof SettlementSimulationError) { + if (error instanceof ChannelSimulationError) { this.settlementCache.delete(settlementKey); return { success: false, @@ -1560,7 +1566,7 @@ export class UptoSvmScheme implements SchemeNetworkFacilitator { * @param feePayerAddress - Fee-payer address from the challenge * @returns The matching kit signer, or undefined when not managed */ - private resolveFeePayer(feePayerAddress: string): UptoSvmSigner | undefined { + private resolveFeePayer(feePayerAddress: string): PaymentChannelSvmSigner | undefined { if (!this.signer.getAddresses().includes(feePayerAddress as Address)) { return undefined; } diff --git a/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts b/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts deleted file mode 100644 index c12f26462e..0000000000 --- a/typescript/packages/mechanisms/svm/src/upto/facilitator/signer.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Address } from "@solana/kit"; - -import type { FacilitatorSigningCapabilities, FacilitatorSvmSigner } from "../../signer"; - -/** - * {@link FacilitatorSvmSigner} narrowed to the optional RPC caps the `upto` - * facilitator requires at runtime. Exact-only signers omit the read methods. - */ -export type UptoFacilitatorSigner = FacilitatorSvmSigner & { - getAccountInfo: NonNullable; - getLatestBlockhash: NonNullable; - getSlot: NonNullable; - getSigner(feePayer: Address): FacilitatorSigningCapabilities; -}; - -/** - * Assert a facilitator signer exposes every optional cap `upto` needs. - * - * @param signer - Facilitator signer to validate - * @param label - Scheme or component name for error messages - * @throws Error when a required capability is missing - */ -export function assertUptoFacilitatorSigner( - signer: FacilitatorSvmSigner, - label = "UptoSvmScheme", -): asserts signer is UptoFacilitatorSigner { - if (typeof signer.getSigner !== "function") { - throw new Error(`${label} requires getSigner on the signer.`); - } - if (typeof signer.getAccountInfo !== "function") { - throw new Error(`${label} requires getAccountInfo on the signer.`); - } - if (typeof signer.getLatestBlockhash !== "function") { - throw new Error(`${label} requires getLatestBlockhash on the signer.`); - } - if (typeof signer.getSlot !== "function") { - throw new Error(`${label} requires getSlot on the signer.`); - } -} diff --git a/typescript/packages/mechanisms/svm/src/upto/shared.ts b/typescript/packages/mechanisms/svm/src/upto/shared.ts index be0770abc7..dc3e588c34 100644 --- a/typescript/packages/mechanisms/svm/src/upto/shared.ts +++ b/typescript/packages/mechanisms/svm/src/upto/shared.ts @@ -1,92 +1,7 @@ import type { PaymentRequirements } from "@x402/core/types"; -import { TOKEN_2022_PROGRAM_ADDRESS, TOKEN_PROGRAM_ADDRESS } from "../constants"; +import { BASIS_POINTS_DENOMINATOR } from "../payment-channels/commitments"; import type { ChannelSplit } from "../payment-channels/open"; -import { getStablecoinTokenProgram, validateSvmAddress } from "../utils"; - -/** Denominator a `ChannelSplit.bps` is measured against; 10_000 = 100%. */ -export const BASIS_POINTS_DENOMINATOR = 10_000; - -/** - * Commitment for reading account state the caller must act on. Opens are - * confirmed at this level, and the RPC default (`finalized`) lags a fresh open - * by seconds, reporting a live channel as missing. - * - * The Go SDK reads the same class of state at the same level, so both - * facilitators judge the same channel identically. - */ -export const STATE_COMMITMENT = "confirmed" as const; - -/** - * Commitment for reading the slot used as an `openSlot` anchor. Clients pin - * `openSlot` at this level to keep `openSlot <= clock.slot` when the open lands, - * so verify and the reclaim gate must judge it in the same frame. - */ -export const SLOT_COMMITMENT = "finalized" as const; - -/** - * Commitment for reading transaction-lifetime blockhashes. A finalized hash - * cannot be dropped by a fork before the transaction lands. - */ -export const BLOCKHASH_COMMITMENT = "finalized" as const; - -/** - * Read and validate `extra.tokenProgram`. - * - * A hint that is not one of the two supported programs is a broken challenge and - * throws, rather than failing later as an opaque open-transaction mismatch. - * Returning `undefined` for an absent hint lets callers pick their own fallback: - * the registry for the facilitator, a mint read for the client. - * - * @param extra - The `extra` block of the payment requirements - * @returns The hinted token program address, or undefined when unset - */ -export function parseTokenProgramHint(extra: PaymentRequirements["extra"]): string | undefined { - const hint = extra?.tokenProgram; - if (hint === undefined || hint === null || hint === "") { - return undefined; - } - if (typeof hint !== "string" || !validateSvmAddress(hint)) { - throw new Error(`extra.tokenProgram ${String(hint)} is not a valid base58 address`); - } - if (hint !== TOKEN_PROGRAM_ADDRESS && hint !== TOKEN_2022_PROGRAM_ADDRESS) { - throw new Error(`extra.tokenProgram ${hint} is not a supported SPL token program`); - } - return hint; -} - -/** - * Resolve the SPL token program owning the requirement's mint. - * - * The challenge hint wins; otherwise the registry answers, so a Token-2022 - * stablecoin is not mistaken for a legacy SPL Token one. - * - * @param requirements - The payment requirements being paid - * @returns The token program address - */ -export function resolveTokenProgram(requirements: PaymentRequirements): string { - return ( - parseTokenProgramHint(requirements.extra) ?? - getStablecoinTokenProgram(requirements.asset, requirements.network) - ); -} - -/** - * Read the optional seller memo (`extra.memo`). - * - * A non-empty string is a requirement: the client emits exactly that memo and - * the facilitator demands a match. Missing, empty, or non-string is unset, so - * the client falls back to a random nonce and the facilitator does not check - * it. Both roles resolve through here, which keeps them from disagreeing on - * whether a memo was requested. Matches the Go SDK's `ParseExtraMemo`. - * - * @param extra - The `extra` map from the payment requirements - * @returns The requested memo, or undefined when the seller set none - */ -export function resolveUptoSvmMemo(extra: PaymentRequirements["extra"]): string | undefined { - const memo = extra?.memo; - return typeof memo === "string" && memo !== "" ? memo : undefined; -} /** Resolved payment-channel fields derived from SVM `upto` requirements. */ export interface UptoSvmPaymentChannelConfig { diff --git a/typescript/packages/mechanisms/svm/src/utils.ts b/typescript/packages/mechanisms/svm/src/utils.ts index 4e3268a68f..5b007965b4 100644 --- a/typescript/packages/mechanisms/svm/src/utils.ts +++ b/typescript/packages/mechanisms/svm/src/utils.ts @@ -36,7 +36,7 @@ import { } from "./constants"; import { DEFAULT_ASSETS, findDefaultAsset, getDefaultAsset } from "./defaultAssets"; import type { ExactSvmPayloadV1 } from "./types"; -import { SLOT_COMMITMENT } from "./upto/shared"; +import { SLOT_COMMITMENT } from "./payment-channels/commitments"; export { normalizeNetwork } from "./constants"; diff --git a/typescript/packages/mechanisms/svm/test/integrations/batch-settlement-svm.test.ts b/typescript/packages/mechanisms/svm/test/integrations/batch-settlement-svm.test.ts new file mode 100644 index 0000000000..11d7e9dea4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/integrations/batch-settlement-svm.test.ts @@ -0,0 +1,648 @@ +import { generateKeyPairSigner, type KeyPairSigner } from "@solana/kit"; +import { x402Client } from "@x402/core/client"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { FacilitatorClient, x402ResourceServer } from "@x402/core/server"; +import type { + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + SupportedResponse, + VerifyResponse, +} from "@x402/core/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { BatchSvmScheme as BatchClientScheme } from "../../src/batch-settlement/client/scheme"; +import { BatchSvmScheme as BatchFacilitatorScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { BatchSvmScheme as BatchServerScheme } from "../../src/batch-settlement/server/scheme"; +import { BatchChannelManager } from "../../src/batch-settlement/server/channelManager"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; +import { fetchMaybeChannel } from "../../src/payment-channels/generated/accounts/channel"; +import { ChannelStatus } from "../../src/payment-channels/generated/types/channelStatus"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../src/payment-channels/onchain"; +import { + encodeVoucherMessageBytes, + verifyVoucherSignature, +} from "../../src/payment-channels/voucher"; +import { toFacilitatorSvmSigner } from "../../src/signer"; +import { createRpcClient } from "../../src/utils"; + +/** + * A local Surfnet (`surfpool start --network devnet`) serves both the deployed + * payment-channels program and the devnet USDC mint, and its cheatcodes fund + * accounts without faucets — so this runs the real onchain flow with no + * pre-provisioned keys. Point `SVM_RPC_URL` at any RPC that has the program. + */ +const RPC_URL = process.env.SVM_RPC_URL ?? "http://127.0.0.1:8899"; +const NETWORK: Network = SOLANA_DEVNET_CAIP2; +const PRICE = "1000"; +const DEPOSIT = "10000"; +const WITHDRAW_DELAY = 900; + +/** Whether the RPC is up and actually carries the payment-channels program. */ +async function chainIsReady(): Promise { + try { + const rpc = createRpcClient(NETWORK, RPC_URL); + const program = await rpc + .getAccountInfo(PAYMENT_CHANNELS_PROGRAM_ID, { encoding: "base64" }) + .send(); + if (!program.value?.executable) return false; + const mint = await rpc + .getAccountInfo(USDC_DEVNET_ADDRESS as never, { encoding: "base64" }) + .send(); + return mint.value !== null; + } catch { + return false; + } +} + +const ready = await chainIsReady(); +if (!ready) { + console.warn( + `[batch-settlement-svm] skipping: no payment-channels program at ${RPC_URL}. ` + + "Start one with: surfpool start --network devnet --no-deploy", + ); +} +const describeOnChain = ready ? describe : describe.skip; + +/** Call a Surfnet cheatcode. */ +async function cheat(method: string, params: unknown[]): Promise { + const response = await fetch(RPC_URL, { + body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { error?: { message?: string } }; + if (body.error) throw new Error(`${method} failed: ${body.error.message ?? "unknown"}`); +} + +/** Give `address` lamports to pay rent and fees with. */ +async function fundSol(address: string, lamports: number): Promise { + await cheat("surfnet_setAccount", [ + address, + { data: "", executable: false, lamports, owner: "11111111111111111111111111111111" }, + ]); +} + +/** Give `address` a USDC balance to escrow. */ +async function fundUsdc(address: string, amount: number): Promise { + await cheat("surfnet_setTokenAccount", [ + address, + USDC_DEVNET_ADDRESS, + { amount }, + TOKEN_PROGRAM_ADDRESS, + ]); +} + +/** The owner's USDC balance in base units, or 0 when it holds no account. */ +async function usdcBalance(owner: string): Promise { + const response = await fetch(RPC_URL, { + body: JSON.stringify({ + id: 1, + jsonrpc: "2.0", + method: "getTokenAccountsByOwner", + params: [owner, { mint: USDC_DEVNET_ADDRESS }, { encoding: "jsonParsed" }], + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const body = (await response.json()) as { + result?: { + value?: { account: { data: { parsed: { info: { tokenAmount: { amount: string } } } } } }[]; + }; + }; + return (body.result?.value ?? []).reduce( + (total, row) => total + BigInt(row.account.data.parsed.info.tokenAmount.amount), + 0n, + ); +} + +/** Wraps the in-process facilitator so the resource server can call it. */ +class SvmFacilitatorClient implements FacilitatorClient { + /** @param facilitator - The facilitator to wrap */ + constructor(private readonly facilitator: x402Facilitator) {} + + /** + * @param payload - Payment payload to verify + * @param requirements - Requirements to verify against + * @returns The facilitator's verification result + */ + verify(payload: PaymentPayload, requirements: PaymentRequirements): Promise { + return this.facilitator.verify(payload, requirements); + } + + /** + * @param payload - Payment payload to settle + * @param requirements - Requirements to settle against + * @returns The facilitator's settlement result + */ + settle(payload: PaymentPayload, requirements: PaymentRequirements): Promise { + return this.facilitator.settle(payload, requirements); + } + + /** @returns The payment kinds the facilitator supports */ + getSupported(): Promise { + return Promise.resolve(this.facilitator.getSupported()); + } +} + +describe("batch-settlement SVM onchain", () => { + describeOnChain("open, pay, top up, redeem, refund", () => { + let payer: KeyPairSigner; + let operator: KeyPairSigner; + let receiver: KeyPairSigner; + let receiverAuthorizer: KeyPairSigner; + + beforeAll(async () => { + payer = await generateKeyPairSigner(); + operator = await generateKeyPairSigner(); + receiver = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + await fundSol(payer.address, 10_000_000_000); + await fundSol(operator.address, 10_000_000_000); + await fundSol(receiver.address, 10_000_000_000); + await fundUsdc(payer.address, 1_000_000); + // The payout destinations must exist before an escrow is accepted. + await fundUsdc(operator.address, 0); + await fundUsdc(receiver.address, 0); + lifecycleClient = new BatchClientScheme(payer, { + depositAmount: DEPOSIT, + rpcUrl: RPC_URL, + }); + }); + + /** The client that owns the channel through the lifecycle stages. */ + let lifecycleClient: BatchClientScheme; + + /** + * A pipeline over `store`, using the lifecycle client unless a cold one is + * asked for — a fresh client has no local record and rediscovers. + */ + function pipeline(store = new MemoryChannelStore(), coldClient = false) { + const facilitator = new x402Facilitator().register( + NETWORK, + new BatchFacilitatorScheme(toFacilitatorSvmSigner(operator, { defaultRpcUrl: RPC_URL }), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + const server = new x402ResourceServer(new SvmFacilitatorClient(facilitator)); + server.register( + NETWORK, + new BatchServerScheme({ receiverAuthorizer, store, withdrawDelay: WITHDRAW_DELAY }), + ); + const clientScheme = coldClient + ? new BatchClientScheme(payer, { depositAmount: DEPOSIT, rpcUrl: RPC_URL }) + : lifecycleClient; + const client = new x402Client().register(NETWORK, clientScheme); + return { client, clientScheme, server, store }; + } + + function accepts(): PaymentRequirements[] { + return [ + { + amount: PRICE, + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: operator.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: receiver.address, + scheme: "batch-settlement", + }, + ]; + } + + const resource = { url: "https://example.test/paid" }; + + /** The channel this suite opens, and the server state that tracks it. */ + let channelId: string; + let lifecycleStore: MemoryChannelStore; + + it( + "opens a channel, serves a paid request, and charges it once", + { timeout: 120_000 }, + async () => { + lifecycleStore = new MemoryChannelStore(); + const { client, clientScheme, server, store } = pipeline(lifecycleStore); + await server.initialize(); + + const required = await server.createPaymentRequiredResponse(accepts(), resource); + const payload = await client.createPaymentPayload(required); + expect((payload.payload as { type: string }).type).toBe("deposit"); + + const matched = server.findMatchingRequirements(accepts(), payload); + const verified = await server.verifyPayment(payload, matched!); + expect(verified.isValid, JSON.stringify(verified)).toBe(true); + + const settled = await server.settlePayment(payload, matched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + + channelId = (payload.payload as { voucher: { channelId: string } }).voucher.channelId; + const rpc = createRpcClient(NETWORK, RPC_URL); + const channel = await fetchMaybeChannel(rpc, channelId as never); + expect(channel.exists).toBe(true); + if (channel.exists) { + expect(channel.data.deposit).toBe(BigInt(DEPOSIT)); + expect(channel.data.status).toBe(ChannelStatus.Open); + } + expect((await store.get(channelId))?.chargedCumulativeAmount).toBe(BigInt(PRICE)); + + // The transport hands the response back to the client, which is what + // commits its pending allocation; without it the client would keep + // re-offering the same open. + await clientScheme.schemeHooks.onPaymentResponse!({ + paymentPayload: payload, + requirements: matched!, + settleResponse: settled, + } as never); + }, + ); + + it("serves a steady-state voucher with no transaction", { timeout: 120_000 }, async () => { + const { client, clientScheme, server } = pipeline(lifecycleStore); + await server.initialize(); + const required = await server.createPaymentRequiredResponse(accepts(), resource); + // This client opened the channel in the previous stage, so it pays + // with a voucher alone — no onchain transaction in the request path. + const payload = await client.createPaymentPayload(required); + expect((payload.payload as { type: string }).type).toBe("voucher"); + + const matched = server.findMatchingRequirements(accepts(), payload); + expect((await server.verifyPayment(payload, matched!)).isValid).toBe(true); + const settled = await server.settlePayment(payload, matched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + expect(settled.transaction).toBe(""); + expect(settled.extra?.chargedAmount).toBe(PRICE); + + // Advancing the client is what the HTTP transport does with the + // response; it also exercises the checks the client makes on it. + await clientScheme.schemeHooks.onPaymentResponse!({ + paymentPayload: payload, + requirements: matched!, + settleResponse: settled, + } as never); + expect((await lifecycleStore.get(channelId))?.chargedCumulativeAmount).toBe(2_000n); + }); + + it( + "resynchronizes a rediscovered client through a corrective 402", + { timeout: 120_000 }, + async () => { + // A client that kept no local record finds the channel it already + // funded rather than opening a second one — but the base it adopts is + // the onchain settled watermark, which lags what the server has + // charged offchain. + const { client, clientScheme, server } = pipeline(lifecycleStore, true); + await server.initialize(); + const required = await server.createPaymentRequiredResponse(accepts(), resource); + const payload = await client.createPaymentPayload(required); + expect( + (payload.payload as { voucher: { channelId: string } }).voucher.channelId, + "the client reused the channel it had already opened", + ).toBe(channelId); + + const matched = server.findMatchingRequirements(accepts(), payload); + const verified = await server.verifyPayment(payload, matched!); + expect(verified.isValid).toBe(false); + expect(verified.invalidReason).toBe( + "invalid_batch_settlement_svm_cumulative_amount_mismatch", + ); + + const corrective = await server.createPaymentRequiredResponse( + accepts(), + resource, + verified.invalidReason, + undefined, + undefined, + payload, + ); + const accept = corrective.accepts.find(a => a.scheme === "batch-settlement"); + expect(accept?.extra?.channelState).toMatchObject({ chargedCumulativeAmount: "2000" }); + // The proof is the client's own signature at that amount. + expect(accept?.extra?.voucherState).toMatchObject({ signedMaxClaimable: "2000" }); + + const recovered = await clientScheme.schemeHooks.onPaymentResponse!({ + paymentPayload: payload, + paymentRequired: corrective, + requirements: matched!, + settleResponse: { success: false }, + } as never); + expect(recovered, "the client verified the proof and adopted the base").toMatchObject({ + recovered: true, + }); + + const retry = await client.createPaymentPayload(required); + expect( + (retry.payload as { voucher: { maxClaimableAmount: string } }).voucher.maxClaimableAmount, + ).toBe("3000"); + const retryMatched = server.findMatchingRequirements(accepts(), retry); + expect((await server.verifyPayment(retry, retryMatched!)).isValid).toBe(true); + const settled = await server.settlePayment(retry, retryMatched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + expect((await lifecycleStore.get(channelId))?.chargedCumulativeAmount).toBe(3_000n); + }, + ); + + it("rebuilds a lost server record from chain", { timeout: 120_000 }, async () => { + // The operator lost its store. The channel is open and funded onchain, + // and the client is still holding a usable voucher base. + const emptyStore = new MemoryChannelStore(); + const { client, server } = pipeline(emptyStore, true); + await server.initialize(); + const required = await server.createPaymentRequiredResponse(accepts(), resource); + const payload = await client.createPaymentPayload(required); + const matched = server.findMatchingRequirements(accepts(), payload); + + const verified = await server.verifyPayment(payload, matched!); + const rebuilt = await emptyStore.get(channelId); + expect(rebuilt, "the record was rebuilt from confirmed onchain state").toBeDefined(); + expect(rebuilt?.deposit).toBe(BigInt(DEPOSIT)); + // Both sides rebuild from the same onchain watermark — the server from + // the facilitator's snapshot, the client from its own scan — so they + // agree on the base and the request is simply served. Before this, the + // server refused every voucher on a funded, open channel. + expect(rebuilt?.chargedCumulativeAmount).toBe(rebuilt?.settled); + expect(verified.isValid, JSON.stringify(verified)).toBe(true); + const settled = await server.settlePayment(payload, matched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + }); + + it("redeems through the channel manager", { timeout: 180_000 }, async () => { + // The worker an operator actually runs: it reads its own channel store, + // claims what has vouchers, then distributes what those claims settled. + const facilitatorSigner = toFacilitatorSvmSigner(operator, { defaultRpcUrl: RPC_URL }); + const facilitator = new x402Facilitator().register( + NETWORK, + new BatchFacilitatorScheme(facilitatorSigner, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + const before = await usdcBalance(receiver.address); + const errors: unknown[] = []; + const manager = new BatchChannelManager({ + onError: error => errors.push(error), + receiverAuthorizer, + rpcUrl: RPC_URL, + requirements: accepts()[0]!, + settle: (payload, requirements) => facilitator.settle(payload as never, requirements), + store: lifecycleStore, + }); + + const redeemed = await manager.redeem(); + expect(errors, JSON.stringify(errors.map(String))).toEqual([]); + expect(redeemed.claimed).toEqual([channelId]); + expect(redeemed.distributed).toEqual([channelId]); + + const rpc = createRpcClient(NETWORK, RPC_URL); + const settledOnchain = await fetchMaybeChannel(rpc, channelId as never); + const charged = (await lifecycleStore.get(channelId))!.chargedCumulativeAmount; + expect(settledOnchain.exists && settledOnchain.data.settlement.settled).toBe(charged); + expect(settledOnchain.exists && settledOnchain.data.settlement.payoutWatermark).toBe(charged); + // The receiver holds the money, and a second pass finds nothing to do. + expect(await usdcBalance(receiver.address)).toBeGreaterThan(before); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [] }); + }); + + it("tops up a channel whose escrow is spent", { timeout: 120_000 }, async () => { + // Its own channel, opened with exactly one request of escrow, so the + // second request has to add more. + const store = new MemoryChannelStore(); + const thrifty = new BatchClientScheme(payer, { + depositAmount: PRICE, + rpcUrl: RPC_URL, + salt: "1", + }); + const facilitator = new x402Facilitator().register( + NETWORK, + new BatchFacilitatorScheme(toFacilitatorSvmSigner(operator, { defaultRpcUrl: RPC_URL }), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + const server = new x402ResourceServer(new SvmFacilitatorClient(facilitator)); + server.register( + NETWORK, + new BatchServerScheme({ receiverAuthorizer, store, withdrawDelay: WITHDRAW_DELAY }), + ); + await server.initialize(); + const client = new x402Client().register(NETWORK, thrifty); + + const pay = async () => { + const required = await server.createPaymentRequiredResponse(accepts(), resource); + const payload = await client.createPaymentPayload(required); + const matched = server.findMatchingRequirements(accepts(), payload); + const verified = await server.verifyPayment(payload, matched!); + expect(verified.isValid, JSON.stringify(verified)).toBe(true); + const settled = await server.settlePayment(payload, matched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + await thrifty.schemeHooks.onPaymentResponse!({ + paymentPayload: payload, + requirements: matched!, + settleResponse: settled, + } as never); + return payload.payload as { type: string; voucher: { channelId: string } }; + }; + + const opened = await pay(); + expect(opened.type).toBe("deposit"); + const rpc = createRpcClient(NETWORK, RPC_URL); + const afterOpen = await fetchMaybeChannel(rpc, opened.voucher.channelId as never); + expect(afterOpen.exists && afterOpen.data.deposit).toBe(BigInt(PRICE)); + + // The escrow is spent, so this one carries a top-up transaction. + const toppedUp = await pay(); + expect(toppedUp.type).toBe("deposit"); + expect(toppedUp.voucher.channelId).toBe(opened.voucher.channelId); + const afterTopUp = await fetchMaybeChannel(rpc, opened.voucher.channelId as never); + expect(afterTopUp.exists && afterTopUp.data.deposit).toBe(2n * BigInt(PRICE)); + expect((await store.get(opened.voucher.channelId))?.chargedCumulativeAmount).toBe( + 2n * BigInt(PRICE), + ); + }); + + it("starts the payer-forced close", { timeout: 120_000 }, async () => { + const { clientScheme, server } = pipeline(lifecycleStore); + await server.initialize(); + const required = await server.createPaymentRequiredResponse(accepts(), resource); + const requirements = required.accepts.find(a => a.scheme === "batch-settlement")!; + const refund = await clientScheme.createRefundPayload(2, requirements); + const payload = { accepted: requirements, ...refund } as never; + + const matched = server.findMatchingRequirements(accepts(), payload); + expect((await server.verifyPayment(payload, matched!)).isValid).toBe(true); + const settled = await server.settlePayment(payload, matched!); + expect(settled.success, JSON.stringify(settled)).toBe(true); + + const rpc = createRpcClient(NETWORK, RPC_URL); + const closing = await fetchMaybeChannel(rpc, channelId as never); + expect(closing.exists && closing.data.status).toBe(ChannelStatus.Closing); + // A closed channel takes no further charge. + expect((await lifecycleStore.get(channelId))?.status).toBe("closing"); + }); + }); + + describeOnChain("operator voucher signing", () => { + it( + "reuses the payer proof while the operator signs and redeems cumulative vouchers", + { timeout: 180_000 }, + async () => { + const payer = await generateKeyPairSigner(); + const feePayer = await generateKeyPairSigner(); + const voucherOperator = await generateKeyPairSigner(); + const receiver = await generateKeyPairSigner(); + await fundSol(payer.address, 10_000_000_000); + await fundSol(feePayer.address, 10_000_000_000); + await fundSol(receiver.address, 10_000_000_000); + await fundUsdc(payer.address, 1_000_000); + await fundUsdc(feePayer.address, 0); + await fundUsdc(receiver.address, 0); + + const store = new MemoryChannelStore(); + const receiverAuthorizer = await generateKeyPairSigner(); + const facilitatorSigner = toFacilitatorSvmSigner(feePayer, { defaultRpcUrl: RPC_URL }); + const facilitator = new x402Facilitator().register( + NETWORK, + new BatchFacilitatorScheme(facilitatorSigner, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + const server = new x402ResourceServer(new SvmFacilitatorClient(facilitator)); + server.register( + NETWORK, + new BatchServerScheme({ + operator: voucherOperator, + receiverAuthorizer, + store, + withdrawDelay: WITHDRAW_DELAY, + }), + ); + await server.initialize(); + const clientScheme = new BatchClientScheme(payer, { + depositAmount: DEPOSIT, + rpcUrl: RPC_URL, + salt: "2", + // Delegating voucher signing to the operator is an explicit client + // decision, never something a 402 can ask for. + serverSignedChannelsPolicy: { allowedOperators: [voucherOperator.address] }, + }); + const client = new x402Client().register(NETWORK, clientScheme); + const accepts: PaymentRequirements[] = [ + { + amount: PRICE, + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: feePayer.address, + operator: voucherOperator.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: WITHDRAW_DELAY, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: receiver.address, + scheme: "batch-settlement", + }, + ]; + const required = await server.createPaymentRequiredResponse(accepts, { + url: "https://example.test/operator-paid", + }); + + const pay = async (amount: string) => { + const payload = await client.createPaymentPayload(required); + const matched = server.findMatchingRequirements(required.accepts, payload)!; + const verified = await server.verifyPayment(payload, matched); + expect(verified.isValid, JSON.stringify(verified)).toBe(true); + // The requirement is a per-request ceiling. Metering happens after + // the handler, and server mode signs only the actual charge. + const settled = await server.settlePayment(payload, matched, undefined, undefined, { + amount, + }); + expect(settled.success, JSON.stringify(settled)).toBe(true); + await clientScheme.schemeHooks.onPaymentResponse!({ + paymentPayload: payload, + requirements: matched, + settleResponse: settled, + } as never); + return { matched, payload }; + }; + + const first = await pay("400"); + const firstPayload = first.payload.payload as { + authorization: { channelId: string; requestId: string; signature: string }; + channelConfig: { payerAuthorizer: string; voucherSigner?: string }; + type: string; + voucher?: unknown; + }; + expect(firstPayload).toMatchObject({ + type: "deposit", + channelConfig: { + payerAuthorizer: voucherOperator.address, + voucherSigner: "server", + }, + }); + expect(firstPayload.voucher).toBeUndefined(); + + // Server vouchers are cumulative, so the client keeps one request in + // flight per channel and advances from an exact confirmed watermark. + const second = await pay("200"); + const third = await pay("300"); + const secondPayment = second.payload; + const thirdPayment = third.payload; + + const secondPayload = secondPayment.payload as { + authorization: { expiresAt: number; requestId: string; signature: string }; + type: string; + }; + expect(secondPayload.type).toBe("authorization"); + expect(secondPayload.authorization.expiresAt).toBeGreaterThan( + Math.floor(Date.now() / 1000), + ); + const thirdPayload = thirdPayment.payload as { + authorization: { requestId: string }; + type: string; + }; + expect(secondPayload.authorization.requestId).not.toBe( + firstPayload.authorization.requestId, + ); + expect(thirdPayload.authorization.requestId).not.toBe( + secondPayload.authorization.requestId, + ); + + const channelId = firstPayload.authorization.channelId; + const state = await store.get(channelId); + expect(state?.chargedCumulativeAmount).toBe(900n); + expect(state?.highestVoucherSignature).toBeDefined(); + expect( + await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: state!.chargedCumulativeAmount, + expiresAt: 0n, + }), + signatureBase58: state!.highestVoucherSignature!, + signerBase58: voucherOperator.address, + }), + ).toBe(true); + + const before = await usdcBalance(receiver.address); + const manager = new BatchChannelManager({ + receiverAuthorizer, + requirements: matched, + rpcUrl: RPC_URL, + settle: (payload, requirements) => facilitator.settle(payload as never, requirements), + store, + }); + expect(await manager.redeem()).toEqual({ + claimed: [channelId], + distributed: [channelId], + }); + expect(await usdcBalance(receiver.address)).toBe(before + 900n); + }, + ); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.edges.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.edges.test.ts new file mode 100644 index 0000000000..3c90d27d2d --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.edges.test.ts @@ -0,0 +1,446 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentRequirements, SettleResponse } from "@x402/core/types"; +import { describe, expect, it } from "vitest"; + +import { verifyCloseAuthorization } from "../../src/batch-settlement/closeAuthorization"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { BatchChannelManager } from "../../src/batch-settlement/server/channelManager"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import type { ChannelState, ChannelStore } from "../../src/batch-settlement/server/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const RECEIVER = USDC_MAINNET_ADDRESS; +const receiverAuthorizer = await generateKeyPairSigner(); +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { feePayer: RECEIVER, tokenProgram: TOKEN_PROGRAM_ADDRESS, withdrawDelay: 900 }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function channel(id: string, overrides: Partial = {}): ChannelState { + return { + channelConfig: { + openSlot: 1, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + receiver: RECEIVER, + salt: "0", + token: USDC_DEVNET_ADDRESS, + withdrawDelay: 900, + }, + channelId: id, + chargedCumulativeAmount: 3_000n, + deposit: 10_000n, + feePayer: RECEIVER, + highestVoucherExpiresAt: 0, + highestVoucherSignature: `sig-${id}`, + mint: USDC_DEVNET_ADDRESS, + openSlot: 1n, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + payoutWatermark: 0n, + receiver: RECEIVER, + salt: 0n, + settled: 0n, + signedMaxClaimable: 3_000n, + status: "open", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + ...overrides, + }; +} + +type Payload = { + type: string; + claims?: { voucher: { channelId: string; expiresAt: number; maxClaimableAmount: string } }[]; + channels?: { channelId: string }[]; +}; + +function ok(): SettleResponse { + return { network: SOLANA_DEVNET_CAIP2, success: true, transaction: "sig" }; +} + +/** Answers claims with a bound success and settles per `distribute`. */ +function settler(distribute: (payload: Payload) => SettleResponse) { + const payloads: Payload[] = []; + const settle = async ({ payload }: { payload: unknown }): Promise => { + const raw = payload as Payload; + payloads.push(raw); + if (raw.type === "claim") { + return { + ...ok(), + extra: { + accepts: (raw.claims ?? []).map(({ voucher }) => ({ + channelId: voucher.channelId, + totalClaimed: voucher.maxClaimableAmount, + })), + }, + }; + } + return distribute(raw); + }; + return { payloads, settle }; +} + +function boundDistribute(raw: Payload): SettleResponse { + return { ...ok(), extra: { channels: (raw.channels ?? []).map(c => c.channelId) } }; +} + +describe("batch-settlement redemption worker edge cases", () => { + it("refuses a store that cannot enumerate its channels", async () => { + const store: ChannelStore = { + get: async () => undefined, + put: async () => undefined, + update: async () => channel("x"), + }; + const manager = new BatchChannelManager({ + receiverAuthorizer, + requirements: requirements(), + settle: ok, + store, + }); + await expect(manager.redeem()).rejects.toThrow(/can list its channels/); + }); + + it("claims a voucher without an expiry as a non-expiring one", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a", { highestVoucherExpiresAt: undefined })); + const { payloads, settle } = settler(boundDistribute); + await new BatchChannelManager({ + readPayoutWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + expect(payloads[0]?.claims?.[0]?.voucher.expiresAt).toBe(0); + }); + + it("reports unknown reasons and leaves the work for the next pass", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + await store.put(channel("chan-b", { settled: 3_000n, highestVoucherSignature: undefined })); + const errors: string[] = []; + const settle = async (): Promise => ({ + network: SOLANA_DEVNET_CAIP2, + success: false, + }); + const result = await new BatchChannelManager({ + onError: error => errors.push((error as Error).message), + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + expect(result).toEqual({ claimed: [], distributed: [], sealed: [] }); + expect(errors).toEqual([ + "batch-settlement claim failed: unknown", + "batch-settlement distribute failed: unknown", + ]); + expect((await store.get("chan-a"))?.settled).toBe(0n); + }); + + it("does not record a distribution whose response is unbound or malformed", async () => { + const mismatch = "batch-settlement distribute response channel mismatch"; + const answers: [SettleResponse, string][] = [ + [ + { ...ok(), extra: { channels: ["chan-a"] }, network: "solana:other" }, + "batch-settlement distribute response bound to another network", + ], + [{ ...ok(), extra: { channels: "chan-a" } }, mismatch], + [{ ...ok(), extra: { channels: ["chan-a", "chan-a"] } }, mismatch], + [{ ...ok(), extra: { channels: ["chan-b"] } }, mismatch], + ]; + for (const [answer, expected] of answers) { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a", { settled: 3_000n, highestVoucherSignature: undefined })); + const errors: string[] = []; + const result = await new BatchChannelManager({ + onError: error => errors.push((error as Error).message), + readPayoutWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle: async () => answer, + store, + }).redeem(); + expect(result.distributed).toEqual([]); + expect(errors).toEqual([expected]); + expect((await store.get("chan-a"))?.payoutWatermark).toBe(0n); + } + }); + + it("reconciles from the chain when a facilitator answers with the spec's bare responses", async () => { + // Spec 4.5 defines only success/transaction/network/amount for claim and + // settle. The reference facilitator's `extra.accepts` / `extra.channels` + // are optional enrichment, never a requirement on the server. + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const errors: unknown[] = []; + const result = await new BatchChannelManager({ + onError: error => errors.push(error), + readPayoutWatermark: async () => 3_000n, + readSettledWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle: async () => ok(), + store, + }).redeem(); + expect(errors).toEqual([]); + expect(result).toEqual({ claimed: ["chan-a"], distributed: ["chan-a"], sealed: [] }); + const state = await store.get("chan-a"); + expect(state?.settled).toBe(3_000n); + expect(state?.payoutWatermark).toBe(3_000n); + }); + + it("leaves a bare-response claim unrecorded until the chain shows the watermark", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const errors: string[] = []; + const result = await new BatchChannelManager({ + onError: error => errors.push((error as Error).message), + readPayoutWatermark: async () => 0n, + readSettledWatermark: async () => 2_999n, + receiverAuthorizer, + requirements: requirements(), + settle: async () => ok(), + store, + }).redeem(); + expect(result.claimed).toEqual([]); + expect(errors).toEqual(["confirmed settled watermark unavailable or behind the claim"]); + expect((await store.get("chan-a"))?.settled).toBe(0n); + }); + + it("caps a configured batch size at the spec's four channels", async () => { + const store = new MemoryChannelStore(); + for (const id of ["a", "b", "c", "d", "e"]) await store.put(channel(`chan-${id}`)); + const { payloads, settle } = settler(boundDistribute); + await new BatchChannelManager({ + maxChannelsPerBatch: 8, + readPayoutWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + const claims = payloads.filter(payload => payload.type === "claim"); + expect(claims.map(payload => payload.claims?.length)).toEqual([4, 1]); + }); + + it("seals a closing channel with its latest voucher when a claim reports channel_closing", async () => { + const store = new MemoryChannelStore(); + // Real 32-byte keys: the close authorization binds the channel PDA. + const OPEN_ID = TOKEN_PROGRAM_ADDRESS; + const CLOSING_ID = USDC_DEVNET_ADDRESS; + await store.put(channel(OPEN_ID)); + await store.put(channel(CLOSING_ID)); + const seen: Payload[] = []; + const settle = async (request: { payload: unknown }): Promise => { + const raw = request.payload as Payload; + seen.push(raw); + if (raw.type === "claim") { + // chan-b's payer started a forced close; the facilitator refuses the + // whole batch, then the single chan-a claim lands. + return raw.claims?.some(claim => claim.voucher.channelId === CLOSING_ID) + ? { + network: SOLANA_DEVNET_CAIP2, + success: false, + errorReason: BatchError.CHANNEL_CLOSING, + } + : ok(); + } + return raw.type === "seal" ? { ...ok(), amount: "3000" } : boundDistribute(raw); + }; + const result = await new BatchChannelManager({ + readPayoutWatermark: async () => 3_000n, + readSettledWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + expect(result.claimed).toEqual([OPEN_ID]); + expect(result.sealed).toEqual([CLOSING_ID]); + expect(seen.map(payload => payload.type)).toEqual([ + "claim", + "claim", + "claim", + "seal", + "settle", + ]); + const seal = seen.find(payload => payload.type === "seal") as + | (Payload & { + channelId: string; + voucher: { maxClaimableAmount: string; signature: string; expiresAt: number }; + closeAuthorization: { validBefore: number; signature: string }; + }) + | undefined; + expect(seal).toMatchObject({ + channelId: CLOSING_ID, + voucher: { expiresAt: 0, maxClaimableAmount: "3000", signature: `sig-${CLOSING_ID}` }, + }); + // The authorization binds this channel, voucher and sponsor for the + // receiver authorizer the server advertises. + await expect( + verifyCloseAuthorization( + seal!.closeAuthorization, + { + channelId: CLOSING_ID, + feePayer: RECEIVER, + maxClaimableAmount: 3_000n, + network: SOLANA_DEVNET_CAIP2, + voucherExpiresAt: 0n, + }, + receiverAuthorizer.address, + 300, + ), + ).resolves.toBe(true); + expect(await store.get(CLOSING_ID)).toMatchObject({ + payoutWatermark: 3_000n, + settled: 3_000n, + status: "distributed", + }); + expect((await store.get(OPEN_ID))?.status).toBe("open"); + }); + + it("marks a channel closing and retries its seal after a transient failure", async () => { + const store = new MemoryChannelStore(); + const id = (await generateKeyPairSigner()).address; + await store.put(channel(id)); + const errors: string[] = []; + const seen: string[] = []; + let sealAnswer: SettleResponse = { + errorReason: "settlement_pending", + network: SOLANA_DEVNET_CAIP2, + success: false, + }; + const manager = new BatchChannelManager({ + onError: error => errors.push((error as Error).message), + readPayoutWatermark: async () => 3_000n, + receiverAuthorizer, + requirements: requirements(), + settle: async (request: { payload: unknown }) => { + const type = (request.payload as Payload).type; + seen.push(type); + if (type === "seal") return sealAnswer; + return { + network: SOLANA_DEVNET_CAIP2, + success: false, + errorReason: BatchError.CHANNEL_CLOSING, + }; + }, + store, + }); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [], sealed: [] }); + expect(errors).toEqual(["batch-settlement seal failed: settlement_pending"]); + expect((await store.get(id))?.status).toBe("closing"); + + sealAnswer = ok(); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [], sealed: [id] }); + expect(seen).toEqual(["claim", "seal", "seal"]); + expect(await store.get(id)).toMatchObject({ settled: 3_000n, status: "distributed" }); + }); + + it("does not seal once the grace period has elapsed", async () => { + const store = new MemoryChannelStore(); + const id = (await generateKeyPairSigner()).address; + const closeRequestedAt = Math.floor(Date.now() / 1000) - 900; + await store.put(channel(id, { closeRequestedAt, status: "closing" })); + const errors: string[] = []; + const seen: string[] = []; + const manager = new BatchChannelManager({ + onError: error => errors.push((error as Error).message), + readPayoutWatermark: async () => 0n, + receiverAuthorizer, + requirements: requirements(), + settle: async (request: { payload: unknown }) => { + seen.push((request.payload as Payload).type); + return ok(); + }, + store, + }); + await manager.redeem(); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [], sealed: [] }); + expect(seen).toEqual([]); + expect(errors).toEqual([expect.stringMatching(/grace period elapsed/)]); + expect(await store.get(id)).toMatchObject({ settled: 0n, status: "closing" }); + }); + + it("never lowers watermarks the store already advanced past", async () => { + // Another writer moves the channel further between the snapshot and the + // record; the worker keeps the higher value rather than rewinding it. + const inner = new MemoryChannelStore(); + const store: ChannelStore = { + get: id => inner.get(id), + list: () => inner.list(), + put: state => inner.put(state), + update: (id, updater) => + inner.update(id, current => + updater(current && { ...current, payoutWatermark: 2_500n, settled: 5_000n }), + ), + }; + await store.put(channel("chan-a", { payoutWatermark: 1_000n })); + const { settle } = settler(boundDistribute); + const result = await new BatchChannelManager({ + readPayoutWatermark: async () => 2_000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + expect(result.claimed).toEqual(["chan-a"]); + // The read paid 2000 covers the snapshot's settled 3000? No: it stays payable. + expect(result.distributed).toEqual([]); + const state = await store.get("chan-a"); + expect(state?.settled).toBe(5_000n); + expect(state?.payoutWatermark).toBe(2_500n); + }); + + it("surfaces a channel that vanished between the snapshot and the record", async () => { + const inner = new MemoryChannelStore(); + const store: ChannelStore = { + get: id => inner.get(id), + list: () => inner.list(), + put: state => inner.put(state), + update: (id, updater) => inner.update(id, () => updater(undefined)), + }; + await store.put(channel("chan-a")); + const { settle } = settler(boundDistribute); + await expect( + new BatchChannelManager({ + readPayoutWatermark: async () => 0n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(), + ).rejects.toThrow(/vanished mid-redemption/); + }); + + it("seals a closing channel without closeAuthorization when no signer is configured", async () => { + const store = new MemoryChannelStore(); + const id = (await generateKeyPairSigner()).address; + await store.put(channel(id, { status: "closing" })); + const seen: Record[] = []; + const result = await new BatchChannelManager({ + readPayoutWatermark: async () => 3_000n, + readSettledWatermark: async () => 3_000n, + requirements: requirements(), + settle: async request => { + seen.push(request.payload as Record); + return ok(); + }, + store, + }).redeem(); + expect(result.sealed).toEqual([id]); + const seal = seen.find(payload => payload.type === "seal"); + expect(seal).toMatchObject({ channelId: id, type: "seal" }); + expect(seal).not.toHaveProperty("closeAuthorization"); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.test.ts new file mode 100644 index 0000000000..0782381a70 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.channelManager.test.ts @@ -0,0 +1,387 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentRequirements, SettleResponse } from "@x402/core/types"; +import { describe, expect, it, vi } from "vitest"; + +import { BatchChannelManager } from "../../src/batch-settlement/server/channelManager"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import type { ChannelState } from "../../src/batch-settlement/server/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const RECEIVER = USDC_MAINNET_ADDRESS; +const receiverAuthorizer = await generateKeyPairSigner(); +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: RECEIVER, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function channel(id: string, overrides: Partial = {}): ChannelState { + return { + channelConfig: { + openSlot: 1, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + receiver: RECEIVER, + salt: "0", + token: USDC_DEVNET_ADDRESS, + withdrawDelay: 900, + }, + channelId: id, + chargedCumulativeAmount: 3_000n, + deposit: 10_000n, + feePayer: RECEIVER, + highestVoucherExpiresAt: 0, + highestVoucherSignature: `sig-${id}`, + mint: USDC_DEVNET_ADDRESS, + openSlot: 1n, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + payoutWatermark: 0n, + receiver: RECEIVER, + salt: 0n, + settled: 0n, + signedMaxClaimable: 3_000n, + status: "open", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + ...overrides, + }; +} + +type RedemptionPayload = { + type: string; + claims?: { voucher: { channelId: string; maxClaimableAmount: string } }[]; + channels?: { channelId: string; payoutWatermark: string; settled: string }[]; +}; + +/** Records what the worker submitted, answering however the test asks. */ +function recorder(answer: (payload: RedemptionPayload) => SettleResponse = recovered) { + const submitted: { type: string; channels: string[] }[] = []; + const settle = async (payload: { payload: unknown }): Promise => { + const raw = payload.payload as RedemptionPayload; + submitted.push({ + channels: (raw.claims ?? []) + .map(c => c.voucher.channelId) + .concat((raw.channels ?? []).map(c => c.channelId)), + type: raw.type, + }); + return answer(raw); + }; + return { settle, submitted }; +} + +function ok(): SettleResponse { + return { network: SOLANA_DEVNET_CAIP2, success: true, transaction: "sig" }; +} + +function recovered(payload: RedemptionPayload): SettleResponse { + return { + ...ok(), + extra: + payload.type === "claim" + ? { + accepts: (payload.claims ?? []).map(({ voucher }) => ({ + channelId: voucher.channelId, + totalClaimed: voucher.maxClaimableAmount, + })), + } + : { + channels: (payload.channels ?? []).map(channel => channel.channelId), + }, + }; +} + +describe("batch-settlement redemption worker", () => { + it("does not mark a newer claim paid when a retry recovers an older sweep", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a", { settled: 3000n, payoutWatermark: 1000n })); + const { settle } = recorder(); + const options = { + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }; + const result = await new BatchChannelManager({ + ...options, + readPayoutWatermark: async () => 1000n, + }).redeem(); + expect(result.distributed).toEqual([]); + expect((await store.get("chan-a"))?.payoutWatermark).toBe(1000n); + // A recreated manager retries the unpaid balance and observes the real advance. + const recovered = await new BatchChannelManager({ + ...options, + readPayoutWatermark: async () => 3000n, + }).redeem(); + expect(recovered.distributed).toEqual(["chan-a"]); + }); + + it("keeps payout work after an unavailable or missing account read", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a", { settled: 3000n })); + const { settle } = recorder(); + for (const reader of [ + async () => undefined, + async () => { + throw new Error("RPC unavailable"); + }, + ]) { + await new BatchChannelManager({ + receiverAuthorizer, + requirements: requirements(), + settle, + store, + readPayoutWatermark: reader, + }).redeem(); + expect((await store.get("chan-a"))?.payoutWatermark).toBe(0n); + } + }); + it("claims unclaimed vouchers, then distributes what they settled", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + // Already claimed to 3000 but never paid out. + await store.put(channel("chan-b", { settled: 3_000n })); + // Nothing owed: claimed and distributed. + await store.put(channel("chan-c", { payoutWatermark: 3_000n, settled: 3_000n })); + const { settle, submitted } = recorder(); + + const manager = new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }); + const result = await manager.redeem(); + + // chan-a has a voucher above its watermark; the others do not. + expect(result.claimed).toEqual(["chan-a"]); + // chan-a's claim and chan-b's backlog both need paying out. + expect(result.distributed.sort()).toEqual(["chan-a", "chan-b"]); + expect(submitted.map(s => s.type)).toEqual(["claim", "settle"]); + + // The store now reflects what landed, so the next pass does nothing. + expect((await store.get("chan-a"))?.settled).toBe(3_000n); + expect((await store.get("chan-a"))?.payoutWatermark).toBe(3_000n); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [], sealed: [] }); + }); + + it("packs no more than four channels into one claim", async () => { + const store = new MemoryChannelStore(); + for (let index = 0; index < 9; index += 1) { + await store.put(channel(`chan-${index}`)); + } + const { settle, submitted } = recorder(); + const manager = new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }); + + const result = await manager.redeem(); + expect(result.claimed).toHaveLength(9); + const claims = submitted.filter(s => s.type === "claim"); + // Nine channels, four per transaction: 4 + 4 + 1, and none dropped. + expect(claims.map(c => c.channels.length)).toEqual([4, 4, 1]); + expect(claims.flatMap(c => c.channels).sort()).toEqual( + Array.from({ length: 9 }, (_, i) => `chan-${i}`).sort(), + ); + }); + + it("leaves a failed batch for the next pass instead of recording it", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const errors: unknown[] = []; + const { settle } = recorder(payload => + payload.type === "claim" + ? { + errorReason: "settlement_pending", + network: SOLANA_DEVNET_CAIP2, + success: false, + transaction: "", + } + : recovered(payload), + ); + const manager = new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + onError: error => errors.push(error), + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }); + + const result = await manager.redeem(); + expect(result.claimed).toEqual([]); + expect(errors).toHaveLength(1); + // The watermark is untouched, so the voucher is still there to claim. + expect((await store.get("chan-a"))?.settled).toBe(0n); + }); + + it("repairs merchant state and finishes payout after a lost response and restart", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const pending = recorder(payload => + payload.type === "claim" + ? { + errorReason: "settlement_pending", + network: SOLANA_DEVNET_CAIP2, + success: false, + transaction: "claim-tx", + } + : recovered(payload), + ); + await new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: pending.settle, + store, + }).redeem(); + expect((await store.get("chan-a"))?.settled).toBe(0n); + + const retry = recorder(); + const result = await new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: retry.settle, + store, + }).redeem(); + expect(result).toEqual({ claimed: ["chan-a"], distributed: ["chan-a"], sealed: [] }); + expect((await store.get("chan-a"))?.settled).toBe(3_000n); + expect((await store.get("chan-a"))?.payoutWatermark).toBe(3_000n); + }); + + it("does not advance local state from an unbound success response", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const errors: unknown[] = []; + const { settle } = recorder(() => ok()); + const result = await new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + onError: error => errors.push(error), + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }).redeem(); + + expect(result).toEqual({ claimed: [], distributed: [], sealed: [] }); + expect((await store.get("chan-a"))?.settled).toBe(0n); + expect(errors).toHaveLength(1); + }); + + it("stop with flush runs one final redeem pass", async () => { + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + const { settle, submitted } = recorder(); + const manager = new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }); + manager.start(3600); + await manager.stop({ flush: true }); + expect(submitted.map(entry => entry.type)).toEqual(["claim", "settle"]); + }); + + it("uses empty transaction in lifecycle callbacks when the facilitator omits it", async () => { + const onClaim = vi.fn(); + const onSettle = vi.fn(); + const onSeal = vi.fn(); + const noTx = (payload: RedemptionPayload): SettleResponse => { + const { transaction: _tx, ...rest } = recovered(payload); + void _tx; + return rest; + }; + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + await new BatchChannelManager({ + onClaim, + onSettle, + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: recorder(noTx).settle, + store, + }).redeem(); + expect(onClaim).toHaveBeenCalledWith({ transaction: "", vouchers: 1 }); + expect(onSettle).toHaveBeenCalledWith({ transaction: "" }); + + const id = (await generateKeyPairSigner()).address; + await store.put(channel(id, { status: "closing" })); + await new BatchChannelManager({ + onSeal, + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: recorder(noTx).settle, + store, + }).redeem(); + expect(onSeal).toHaveBeenCalledWith({ channel: id, transaction: "" }); + }); + + it("fires onClaim, onSettle, and onSeal with facilitator transaction signatures", async () => { + const onClaim = vi.fn(); + const onSettle = vi.fn(); + const onSeal = vi.fn(); + const store = new MemoryChannelStore(); + await store.put(channel("chan-a")); + await new BatchChannelManager({ + onClaim, + onSettle, + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: recorder().settle, + store, + }).redeem(); + expect(onClaim).toHaveBeenCalledWith({ transaction: "sig", vouchers: 1 }); + expect(onSettle).toHaveBeenCalledWith({ transaction: "sig" }); + + const id = (await generateKeyPairSigner()).address; + await store.put(channel(id, { status: "closing" })); + await new BatchChannelManager({ + onSeal, + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle: recorder().settle, + store, + }).redeem(); + expect(onSeal).toHaveBeenCalledWith({ channel: id, transaction: "sig" }); + }); + + it("seals a closing channel instead of claiming it", async () => { + const store = new MemoryChannelStore(); + const id = (await generateKeyPairSigner()).address; + await store.put(channel(id, { status: "closing" })); + const { settle, submitted } = recorder(); + const manager = new BatchChannelManager({ + readPayoutWatermark: async () => 3000n, + receiverAuthorizer, + requirements: requirements(), + settle, + store, + }); + expect(await manager.redeem()).toEqual({ claimed: [], distributed: [], sealed: [id] }); + expect(submitted.map(s => s.type)).toEqual(["seal"]); + expect((await store.get(id))?.status).toBe("distributed"); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.client.lifecycle.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.client.lifecycle.test.ts new file mode 100644 index 0000000000..ae16f12a17 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.client.lifecycle.test.ts @@ -0,0 +1,791 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { fetchMint } from "@solana-program/token-2022"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BatchChannelTracker } from "../../src/batch-settlement/client/channel"; +import { + BatchSvmScheme, + type BatchClientChannelRecord, + type BatchClientChannelStorage, +} from "../../src/batch-settlement/client/scheme"; +import type { BatchChannelConfig } from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { signVoucher } from "../../src/payment-channels/voucher"; +import { createRpcClient, resolveBlockhash, resolveOpenSlot } from "../../src/utils"; + +vi.mock("@solana-program/token-2022", async importOriginal => ({ + ...(await importOriginal()), + fetchMint: vi.fn(), +})); + +vi.mock("../../src/utils", async importOriginal => ({ + ...(await importOriginal()), + createRpcClient: vi.fn(() => ({ + getProgramAccounts: vi.fn(() => ({ send: vi.fn().mockResolvedValue([]) })), + })), + resolveBlockhash: vi.fn(), + resolveOpenSlot: vi.fn(), +})); + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const BLOCKHASH = USDC_MAINNET_ADDRESS; + +let payer: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); +}); + +beforeEach(() => { + vi.mocked(fetchMint).mockResolvedValue({ programAddress: TOKEN_PROGRAM_ADDRESS } as never); + vi.mocked(resolveBlockhash).mockResolvedValue({ + blockhash: BLOCKHASH, + lastValidBlockHeight: 10n, + }); + vi.mocked(resolveOpenSlot).mockResolvedValue(123n); + vi.mocked(createRpcClient).mockReturnValue({ + getProgramAccounts: vi.fn(() => ({ send: vi.fn().mockResolvedValue([]) })), + } as never); +}); + +function requirements(overrides: Partial = {}): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + ...overrides, + }; +} + +function memoryStorage() { + const records = new Map(); + const storage: BatchClientChannelStorage = { + delete: vi.fn(async key => void records.delete(key)), + get: vi.fn(async key => records.get(key)), + set: vi.fn(async (key, record) => void records.set(key, record)), + }; + return { records, storage }; +} + +type ClientInternals = { + channels: Map; + pending: Map; + channelKey(requirements: PaymentRequirements, feePayer: string, withdrawDelay: number): string; + resolveTerms(requirements: PaymentRequirements): Promise; + discoverChannel(requirements: PaymentRequirements, terms: unknown): Promise; + loadChannel(key: string): Promise; +}; + +function internals(client: BatchSvmScheme): ClientInternals { + return client as unknown as ClientInternals; +} + +describe("batch client lifecycle", () => { + it.each([false, true])( + "commits a signed server voucher without allocating again (restart=%s)", + async restart => { + const operator = await generateKeyPairSigner(); + const { records, storage } = memoryStorage(); + const trust = { allowedOperators: [operator.address] }; + let client = new BatchSvmScheme(payer, { + channelStorage: storage, + depositAmount: 3_000n, + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }); + const serverRequirements = requirements({ + extra: { + ...requirements().extra, + operator: operator.address, + voucherSigner: "server", + }, + }); + const opened = await client.createPaymentPayload(2, serverRequirements); + expect(opened.payload).toMatchObject({ type: "deposit" }); + expect("maxClaimableAmount" in opened.payload).toBe(false); + const channelId = opened.payload.authorization!.channelId; + const signature = await signVoucher(operator, { + channelId, + cumulativeAmount: 400n, + expiresAt: 0n, + }); + const voucher = { channelId, expiresAt: 0, maxClaimableAmount: "400", signature }; + if (restart) { + client = new BatchSvmScheme(payer, { + channelStorage: storage, + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }); + } + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: serverRequirements, ...opened }, + requirements: serverRequirements, + settleResponse: { + extra: { + channelState: { chargedCumulativeAmount: "400" }, + commitmentId: `${channelId}:400`, + voucher, + }, + success: true, + }, + } as never); + + expect([...records.values()][0]).toMatchObject({ + chargedCumulativeAmount: "400", + deposit: "3000", + }); + + const first = await client.createPaymentPayload(2, serverRequirements); + await expect(client.createPaymentPayload(2, serverRequirements)).rejects.toThrow( + /pending request/, + ); + expect(first.payload).toMatchObject({ type: "authorization" }); + if (first.payload.type !== "authorization") { + throw new Error("expected server-mode authorization payload"); + } + + const response = async (prior: bigint, actual: bigint, cumulative: bigint) => { + const voucherSignature = await signVoucher(operator, { + channelId, + cumulativeAmount: cumulative, + expiresAt: 0n, + }); + const completedVoucher = { + channelId, + expiresAt: 0, + maxClaimableAmount: cumulative.toString(), + signature: voucherSignature, + }; + expect(cumulative).toBe(prior + actual); + return { + extra: { + channelState: { chargedCumulativeAmount: cumulative.toString() }, + commitmentId: `${channelId}:${cumulative}`, + voucher: completedVoucher, + }, + success: true, + }; + }; + + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: serverRequirements, ...first }, + requirements: serverRequirements, + settleResponse: await response(400n, 500n, 900n), + } as never); + const third = await client.createPaymentPayload(2, serverRequirements); + expect(third.payload).toMatchObject({ type: "authorization" }); + if (third.payload.type !== "authorization") { + throw new Error("expected server-mode authorization payload"); + } + expect(third.payload.authorization.requestId).not.toBe(first.payload.authorization.requestId); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: serverRequirements, ...third }, + requirements: serverRequirements, + settleResponse: await response(900n, 300n, 1_200n), + } as never); + expect([...records.values()][0]).toMatchObject({ chargedCumulativeAmount: "1200" }); + }, + ); + + it("opens, replays, confirms, and advances a persisted channel", async () => { + const { records, storage } = memoryStorage(); + const client = new BatchSvmScheme(payer, { + channelStorage: storage, + depositAmount: 3_000n, + discoverChannels: false, + }); + const opened = await client.createPaymentPayload(2, requirements()); + expect(opened.payload).toMatchObject({ + type: "deposit", + deposit: { amount: "3000" }, + voucher: { maxClaimableAmount: "1000" }, + }); + + await expect(client.createPaymentPayload(2, requirements())).resolves.toEqual(opened); + await expect(client.createPaymentPayload(2, requirements({ amount: "2000" }))).rejects.toThrow( + /pending allocation for a different amount/, + ); + + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...opened }, + requirements: requirements(), + settleResponse: { + extra: { + chargedAmount: "1000", + channelState: { chargedCumulativeAmount: "1000" }, + commitmentId: `${opened.payload.voucher.channelId}:1000`, + }, + success: true, + }, + } as never); + expect([...records.values()][0]).toMatchObject({ + chargedCumulativeAmount: "1000", + deposit: "3000", + }); + + const next = await client.createPaymentPayload(2, requirements()); + expect(next.payload).toMatchObject({ + type: "voucher", + voucher: { maxClaimableAmount: "2000" }, + }); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...next }, + requirements: requirements(), + settleResponse: { + extra: { + chargedAmount: "1000", + commitmentId: `${next.payload.voucher.channelId}:2000`, + }, + success: true, + }, + } as never); + expect([...records.values()][0]).toMatchObject({ + chargedCumulativeAmount: "2000", + deposit: "3000", + }); + + const invalidResponse = await client.createPaymentPayload(2, requirements()); + await expect( + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...invalidResponse }, + requirements: requirements(), + settleResponse: { extra: { chargedAmount: "bad" }, success: true }, + } as never), + ).rejects.toThrow(/unexpected amount/); + }); + + it("treats the commitment identifier as opaque and requires only that it is non-empty", async () => { + // Spec 4.4: `extra.commitmentId` MUST be non-empty; `channelId:cumulative` + // is an example, not a format the client may insist on. + const { records, storage } = memoryStorage(); + const client = new BatchSvmScheme(payer, { + channelStorage: storage, + depositAmount: 3_000n, + discoverChannels: false, + }); + const opened = await client.createPaymentPayload(2, requirements()); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...opened }, + requirements: requirements(), + settleResponse: { + extra: { chargedAmount: "1000", commitmentId: "receipt-7f3a" }, + success: true, + }, + } as never); + expect([...records.values()][0]).toMatchObject({ + chargedCumulativeAmount: "1000", + deposit: "3000", + }); + + const next = await client.createPaymentPayload(2, requirements()); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...next }, + requirements: requirements(), + settleResponse: { extra: { chargedAmount: "1000", commitmentId: "" }, success: true }, + } as never); + // An empty identifier is not a confirmation: the watermark stays put. + expect([...records.values()][0]).toMatchObject({ chargedCumulativeAmount: "1000" }); + }); + + it("tops up an exhausted channel and commits only the signed deposit", async () => { + const { records, storage } = memoryStorage(); + const client = new BatchSvmScheme(payer, { + channelStorage: storage, + depositAmount: 1_500n, + discoverChannels: false, + }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + const key = api.channelKey(requirements(), feePayer.address, 900); + const tracker = new BatchChannelTracker(RECEIVER, config, payer, 1_000n); + api.channels.set(key, { deposit: 1_000n, tracker }); + + const topUp = await client.createPaymentPayload(2, requirements()); + expect(topUp.payload).toMatchObject({ + type: "deposit", + deposit: { amount: "1500" }, + voucher: { maxClaimableAmount: "2000" }, + }); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...topUp }, + requirements: requirements(), + settleResponse: { + extra: { + chargedAmount: "1000", + channelState: { balance: "999999", chargedCumulativeAmount: "2000" }, + commitmentId: `${RECEIVER}:2000`, + }, + success: true, + }, + } as never); + expect(records.get(key)).toMatchObject({ chargedCumulativeAmount: "2000", deposit: "2500" }); + }); + + it("tops up by the exact shortfall when the configured increment is smaller", async () => { + const client = new BatchSvmScheme(payer, { + depositAmount: 500n, + discoverChannels: false, + }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + const key = api.channelKey(requirements(), feePayer.address, 900); + api.channels.set(key, { + deposit: 1_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + await expect(client.createPaymentPayload(2, requirements())).resolves.toMatchObject({ + payload: { deposit: { amount: "1000" }, type: "deposit" }, + }); + }); + + it("uses five request charges as the default top-up target", async () => { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + api.channels.set(api.channelKey(requirements(), feePayer.address, 900), { + deposit: 1_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + await expect(client.createPaymentPayload(2, requirements())).resolves.toMatchObject({ + payload: { deposit: { amount: "5000" }, type: "deposit" }, + }); + }); + + it("honors a valid minDeposit hint within the local spend ceiling", async () => { + const hinted = requirements({ + extra: { ...requirements().extra, minDeposit: "15000" }, + }); + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + await expect(client.createPaymentPayload(2, hinted)).resolves.toMatchObject({ + payload: { deposit: { amount: "15000" }, type: "deposit" }, + }); + + const capped = new BatchSvmScheme(payer, { discoverChannels: false }); + await expect( + capped.createPaymentPayload(2, hinted, { maxAmountPerPayment: "2000" }), + ).resolves.toMatchObject({ + payload: { deposit: { amount: "10000" }, type: "deposit" }, + }); + }); + + it("falls back from malformed minDeposit and validates depositMultiplier", async () => { + const malformed = requirements({ + extra: { ...requirements().extra, minDeposit: "500" }, + }); + const client = new BatchSvmScheme(payer, { + depositPolicy: { depositMultiplier: 3 }, + discoverChannels: false, + }); + await expect(client.createPaymentPayload(2, malformed)).resolves.toMatchObject({ + payload: { deposit: { amount: "3000" }, type: "deposit" }, + }); + expect(() => new BatchSvmScheme(payer, { depositPolicy: { depositMultiplier: 2 } })).toThrow( + /integer >= 3/, + ); + }); + + it("rejects a required deposit above the spend-derived ceiling", async () => { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + await expect( + client.createPaymentPayload(2, requirements(), { maxAmountPerPayment: "100" }), + ).rejects.toThrow(/Required deposit 1000 exceeds/); + + const malformedCap = new BatchSvmScheme(payer, { discoverChannels: false }); + await expect( + malformedCap.createPaymentPayload( + 2, + requirements({ extra: { ...requirements().extra, minDeposit: "not-an-amount" } }), + { maxAmountPerPayment: "not-an-amount" }, + ), + ).resolves.toMatchObject({ + payload: { deposit: { amount: "5000" }, type: "deposit" }, + }); + }); + + it("treats empty and failed discovery scans as cache misses", async () => { + const client = new BatchSvmScheme(payer); + const api = internals(client); + const terms = { + feePayer: feePayer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }; + await expect(api.discoverChannel(requirements(), terms)).resolves.toBeUndefined(); + vi.mocked(createRpcClient).mockReturnValue({ + getProgramAccounts: vi.fn(() => ({ send: vi.fn().mockRejectedValue(new Error("rpc")) })), + } as never); + await expect(api.discoverChannel(requirements(), terms)).resolves.toBeUndefined(); + }); + + it("adopts a discovered channel before allocating a voucher", async () => { + const { records, storage } = memoryStorage(); + const client = new BatchSvmScheme(payer, { channelStorage: storage }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + api.discoverChannel = vi.fn().mockResolvedValue({ + deposit: 5_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + await expect(client.createPaymentPayload(2, requirements())).resolves.toMatchObject({ + payload: { type: "voucher", voucher: { maxClaimableAmount: "2000" } }, + }); + expect([...records.values()][0]).toMatchObject({ chargedCumulativeAmount: "1000" }); + }); + + it("preserves the spend-derived deposit ceiling after channel discovery", async () => { + const client = new BatchSvmScheme(payer); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + api.discoverChannel = vi.fn().mockResolvedValue({ + deposit: 0n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 0n), + }); + await expect( + client.createPaymentPayload(2, requirements(), { maxAmountPerPayment: "100" }), + ).rejects.toThrow(/Required deposit 1000 exceeds/); + }); + + it("restores confirmed state after a failed request", async () => { + const { records, storage } = memoryStorage(); + const client = new BatchSvmScheme(payer, { channelStorage: storage }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + const key = api.channelKey(requirements(), feePayer.address, 900); + api.channels.set(key, { + deposit: 5_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + const payment = await client.createPaymentPayload(2, requirements()); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...payment }, + requirements: requirements(), + settleResponse: { success: false }, + } as never); + expect(records.get(key)).toMatchObject({ chargedCumulativeAmount: "1000", deposit: "5000" }); + }); + + it("rejects corrective responses without the required trustworthy state", async () => { + const corrections = [ + { accepts: [requirements()], error: "other" }, + { + accepts: [requirements()], + error: "invalid_batch_settlement_svm_cumulative_amount_mismatch", + }, + { + accepts: [ + requirements({ + extra: { + ...requirements().extra, + channelState: { + balance: "10000", + channelId: RECEIVER, + chargedCumulativeAmount: "1", + totalClaimed: "2", + withdrawRequestedAt: 0, + }, + }, + }), + ], + error: "invalid_batch_settlement_svm_cumulative_amount_mismatch", + }, + ]; + for (const paymentRequired of corrections) { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + const payment = await client.createPaymentPayload(2, requirements()); + await expect( + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...payment }, + paymentRequired: { ...paymentRequired, x402Version: 2 }, + requirements: requirements(), + settleResponse: { success: false }, + } as never), + ).resolves.toBeUndefined(); + } + }); + + it("hydrates confirmed and pending records and ignores unrelated responses", async () => { + const { records, storage } = memoryStorage(); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + records.set("confirmed", { + channelConfig: config, + channelId: RECEIVER, + chargedCumulativeAmount: "1000", + deposit: "5000", + }); + const client = new BatchSvmScheme(payer, { channelStorage: storage }); + await expect(internals(client).loadChannel("confirmed")).resolves.toMatchObject({ + deposit: 5_000n, + }); + expect(await internals(client).loadChannel("confirmed")).toBeDefined(); + await expect(internals(client).loadChannel("missing")).resolves.toBeUndefined(); + await expect( + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { + accepted: requirements(), + payload: { type: "not-batch" }, + x402Version: 2, + }, + requirements: requirements(), + } as never), + ).resolves.toBeUndefined(); + const voucher = await new BatchChannelTracker(RECEIVER, config, payer).previewVoucher(1_000n); + await expect( + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { + accepted: requirements(), + payload: { channelConfig: config, type: "voucher", voucher }, + x402Version: 2, + }, + requirements: requirements(), + settleResponse: { success: false }, + } as never), + ).resolves.toBeUndefined(); + }); + + it("validates client terms and configuration boundaries", async () => { + const client = new BatchSvmScheme(payer); + const resolve = (value: PaymentRequirements) => internals(client).resolveTerms(value); + await expect(resolve(requirements())).resolves.toMatchObject({ + feePayer: feePayer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + await expect( + resolve( + requirements({ + extra: { + ...requirements().extra, + memo: "invoice", + receiverAuthorizer: payer.address, + }, + }), + ), + ).resolves.toMatchObject({ memo: "invoice", receiverAuthorizer: payer.address }); + const serverMode = requirements({ + extra: { + ...requirements().extra, + operator: feePayer.address, + voucherSigner: "server", + }, + }); + // Server mode is never implied by the 402: without a grant the terms are + // refused even though every field is well-formed. + await expect(resolve(serverMode)).rejects.toThrow(/Trust it explicitly/); + await expect( + internals( + new BatchSvmScheme(payer, { + // $0.005 in USDC (6 decimals) = 5000 atomic. + serverSignedChannelsPolicy: { + allowedOperators: [feePayer.address], + maxDeposit: "$0.005", + }, + }), + ).resolveTerms(serverMode), + ).resolves.toMatchObject({ + operator: feePayer.address, + voucherSigner: "server", + trust: { operator: feePayer.address, maxDeposit: 5_000n }, + }); + const invalid = [ + requirements({ extra: undefined }), + requirements({ extra: { ...requirements().extra, paymentFlow: "upfront" } }), + requirements({ extra: { ...requirements().extra, feePayer: "" } }), + requirements({ extra: { ...requirements().extra, withdrawDelay: 899 } }), + requirements({ extra: { ...requirements().extra, tokenProgram: payer.address } }), + requirements({ extra: { ...requirements().extra, receiverAuthorizer: 1 } }), + requirements({ extra: { ...requirements().extra, memo: 1 } }), + requirements({ extra: { ...requirements().extra, voucherSigner: "other" } }), + requirements({ extra: { ...requirements().extra, voucherSigner: "server" } }), + requirements({ extra: { ...requirements().extra, operator: feePayer.address } }), + ]; + for (const value of invalid) await expect(resolve(value)).rejects.toThrow(); + + vi.mocked(fetchMint).mockResolvedValueOnce({ programAddress: payer.address } as never); + await expect(resolve(requirements())).rejects.toThrow(/does not own/); + await expect( + new BatchSvmScheme(payer, { salt: "bad" }).createPaymentPayload(2, requirements()), + ).rejects.toThrow(); + await expect( + new BatchSvmScheme(payer, { + depositAmount: 999n, + discoverChannels: false, + }).createPaymentPayload(2, requirements()), + ).rejects.toThrow(/must cover/); + await expect( + new BatchSvmScheme(payer).createPaymentPayload(2, requirements({ amount: "0" })), + ).rejects.toThrow(/must be positive/); + await expect( + new BatchSvmScheme(payer, { discoverChannels: false }).createPaymentPayload( + 2, + requirements(), + ), + ).resolves.toMatchObject({ payload: { deposit: { amount: "5000" }, type: "deposit" } }); + }); + + it("builds a refund from a cached channel and rejects a missing one", async () => { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + const key = api.channelKey(requirements(), feePayer.address, 900); + api.channels.set(key, { + deposit: 5_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + const cooperative = await client.createRefundPayload(2, requirements()); + expect(cooperative).toMatchObject({ + x402Version: 2, + payload: { type: "refund", voucher: { channelId: RECEIVER, maxClaimableAmount: "1000" } }, + }); + expect(cooperative.payload).not.toHaveProperty("transaction"); + const fallback = await client.createRefundPayload(2, requirements(), { + withTransaction: true, + }); + expect(fallback.payload).toMatchObject({ + transaction: expect.any(String), + voucher: { maxClaimableAmount: "1000" }, + }); + + api.channels.set(key, { + deposit: 5_000n, + tracker: new BatchChannelTracker( + RECEIVER, + { ...config, payerAuthorizer: payer.address, voucherSigner: "server" }, + payer, + 1_000n, + ), + }); + const serverRefund = await client.createRefundPayload(2, requirements()); + expect(serverRefund.payload).toMatchObject({ + authorization: { authorizedAmount: "0", channelId: RECEIVER }, + type: "refund", + }); + expect(serverRefund.payload).not.toHaveProperty("voucher"); + await expect( + new BatchSvmScheme(payer, { discoverChannels: false }).createRefundPayload(2, requirements()), + ).rejects.toThrow(/no batch-settlement channel/); + }); + + it("refunds a client-signed channel when the probe lists server-signed first", async () => { + const operator = await generateKeyPairSigner(); + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + const api = internals(client); + const config: BatchChannelConfig = { + openSlot: 123, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + voucherSigner: "client", + withdrawDelay: 900, + }; + const clientRequirements = requirements(); + const key = api.channelKey(clientRequirements, feePayer.address, 900); + api.channels.set(key, { + deposit: 5_000n, + tracker: new BatchChannelTracker(RECEIVER, config, payer, 1_000n), + }); + const serverFirstProbe = requirements({ + extra: { + ...clientRequirements.extra, + operator: operator.address, + voucherSigner: "server", + }, + }); + const cooperative = await client.createRefundPayload(2, serverFirstProbe); + expect(cooperative).toMatchObject({ + x402Version: 2, + payload: { type: "refund", voucher: { channelId: RECEIVER, maxClaimableAmount: "1000" } }, + }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.client.trust.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.client.trust.test.ts new file mode 100644 index 0000000000..deb99ca42c --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.client.trust.test.ts @@ -0,0 +1,505 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { fetchMint } from "@solana-program/token-2022"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BatchSvmScheme } from "../../src/batch-settlement/client/scheme"; +import { + isServerSignedAccept, + ServerSignedTrustPolicy, + UntrustedOperatorError, +} from "../../src/batch-settlement/client/trust"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { signVoucher } from "../../src/payment-channels/voucher"; +import { createRpcClient, resolveBlockhash, resolveOpenSlot } from "../../src/utils"; + +vi.mock("@solana-program/token-2022", async importOriginal => ({ + ...(await importOriginal()), + fetchMint: vi.fn(), +})); + +vi.mock("../../src/utils", async importOriginal => ({ + ...(await importOriginal()), + createRpcClient: vi.fn(), + resolveBlockhash: vi.fn(), + resolveOpenSlot: vi.fn(), +})); + +const NETWORK = SOLANA_DEVNET_CAIP2; +const ORIGIN = "https://api.example.test"; + +let payer: Awaited>; +let feePayer: Awaited>; +let operator: Awaited>; +let otherOperator: Awaited>; +let receiverAuthorizer: Awaited>; + +beforeAll(async () => { + [payer, feePayer, operator, otherOperator, receiverAuthorizer] = await Promise.all([ + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + generateKeyPairSigner(), + ]); +}); + +beforeEach(() => { + vi.mocked(fetchMint).mockResolvedValue({ programAddress: TOKEN_PROGRAM_ADDRESS } as never); + vi.mocked(resolveBlockhash).mockResolvedValue({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 10n, + }); + vi.mocked(resolveOpenSlot).mockResolvedValue(123n); + vi.mocked(createRpcClient).mockReturnValue({ + getProgramAccounts: vi.fn(() => ({ send: vi.fn().mockResolvedValue([]) })), + } as never); +}); + +function clientAccept(overrides: Partial = {}): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: USDC_MAINNET_ADDRESS, + scheme: "batch-settlement", + ...overrides, + }; +} + +function serverAccept( + operatorAddress = operator.address, + extra: Record = {}, + overrides: Partial = {}, +): PaymentRequirements { + return clientAccept({ + ...overrides, + extra: { + ...clientAccept().extra, + operator: operatorAddress, + voucherSigner: "server", + ...extra, + }, + }); +} + +function evmAccept(): PaymentRequirements { + return { + amount: "1000", + asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + extra: {}, + maxTimeoutSeconds: 300, + network: "eip155:84532", + payTo: "0x0000000000000000000000000000000000000001", + scheme: "batch-settlement", + }; +} + +describe("server-signed trust policy", () => { + it("rejects malformed policies", () => { + expect(() => new ServerSignedTrustPolicy({ allowedOperators: [""] })).toThrow(/non-empty/); + expect( + () => new ServerSignedTrustPolicy({ allowedOperators: [operator.address], maxDeposit: "$0" }), + ).toThrow(/positive/); + expect( + () => + new ServerSignedTrustPolicy({ + allowedAssets: [{ asset: USDC_DEVNET_ADDRESS, maxDeposit: "$1", network: NETWORK }], + allowedOperators: [operator.address], + }), + ).toThrow(/integer atomic amount, not a dollar value/); + expect(isServerSignedAccept(serverAccept())).toBe(true); + expect(isServerSignedAccept(clientAccept())).toBe(false); + }); + + it("grants only listed operators and refuses everything without a policy", () => { + expect(() => new ServerSignedTrustPolicy().grantFor(serverAccept())).toThrow( + UntrustedOperatorError, + ); + const policy = new ServerSignedTrustPolicy({ allowedOperators: [operator.address] }); + expect(() => policy.grantFor(serverAccept(otherOperator.address))).toThrow( + new RegExp(`${otherOperator.address}.*allowedOperators`), + ); + expect(() => policy.grantFor(clientAccept())).toThrow(UntrustedOperatorError); + }); + + it("resolves the escrow cap like the core spend controls", () => { + // Default $1 on a 6-decimal default asset. + expect( + new ServerSignedTrustPolicy({ allowedOperators: [operator.address] }).grantFor( + serverAccept(), + ), + ).toEqual({ operator: operator.address, maxDeposit: 1_000_000n }); + // Configured USD cap. + expect( + new ServerSignedTrustPolicy({ + allowedOperators: [operator.address], + maxDeposit: "$0.05", + }).grantFor(serverAccept()), + ).toEqual({ operator: operator.address, maxDeposit: 50_000n }); + // `false` lifts the cap for default assets. + expect( + new ServerSignedTrustPolicy({ + allowedOperators: [operator.address], + maxDeposit: false, + }).grantFor(serverAccept()), + ).toEqual({ operator: operator.address }); + // A non-default asset needs an explicit entry ... + const exotic = serverAccept(operator.address, {}, { asset: feePayer.address }); + expect(() => + new ServerSignedTrustPolicy({ allowedOperators: [operator.address] }).grantFor(exotic), + ).toThrow(/not a default asset.*allowedAssets/); + // ... whose atomic cap wins over the USD cap, and which may be uncapped. + expect( + new ServerSignedTrustPolicy({ + allowedAssets: [{ asset: feePayer.address, maxDeposit: "777", network: "solana:*" }], + allowedOperators: [operator.address], + }).grantFor(exotic), + ).toEqual({ operator: operator.address, maxDeposit: 777n }); + expect( + new ServerSignedTrustPolicy({ + allowedAssets: [{ asset: feePayer.address, network: NETWORK }], + allowedOperators: [operator.address], + }).grantFor(exotic), + ).toEqual({ operator: operator.address }); + // A default-asset symbol also matches an entry. + expect( + new ServerSignedTrustPolicy({ + allowedAssets: [{ asset: "usdc", maxDeposit: "42", network: NETWORK }], + allowedOperators: [operator.address], + }).grantFor(serverAccept()), + ).toEqual({ operator: operator.address, maxDeposit: 42n }); + }); + + it("filters accepts: drops untrusted server-signed ones and prefers trusted ones", () => { + const policy = new ServerSignedTrustPolicy({ allowedOperators: [operator.address] }); + const evm = evmAccept(); + const client = clientAccept(); + const trusted = serverAccept(); + const untrusted = serverAccept(otherOperator.address); + // Untrusted dropped, client-signed fallback kept, other schemes untouched. + expect(policy.filterAccepts([untrusted, client, evm])).toEqual([client, evm]); + // Trusted server-signed accept moves ahead of the same network's + // client-signed accept; the EVM accept keeps its place. + expect(policy.filterAccepts([evm, client, trusted])).toEqual([evm, trusted, client]); + // Nothing to do when no server-signed accept is offered. + expect(policy.filterAccepts([client, evm])).toEqual([client, evm]); + // Every accept needed an untrusted operator: refuse with an actionable message. + expect(() => policy.filterAccepts([untrusted])).toThrow( + new RegExp(`${otherOperator.address}.*allowedOperators`), + ); + }); +}); + +describe("server-signed channels on the client scheme", () => { + it("never opens a server-signed channel without a grant", async () => { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + await expect(client.createPaymentPayload(2, serverAccept())).rejects.toThrow( + UntrustedOperatorError, + ); + const other = new BatchSvmScheme(payer, { + discoverChannels: false, + serverSignedChannelsPolicy: { allowedOperators: [otherOperator.address] }, + }); + await expect(other.createPaymentPayload(2, serverAccept())).rejects.toThrow( + /Trust it explicitly/, + ); + }); + + it("opens a server-signed channel for a listed operator on any transport", async () => { + const client = new BatchSvmScheme(payer, { + discoverChannels: false, + serverSignedChannelsPolicy: { allowedOperators: [operator.address] }, + }); + const payment = await client.createPaymentPayload(2, serverAccept()); + expect(payment.payload).toMatchObject({ + type: "deposit", + channelConfig: { payerAuthorizer: operator.address, voucherSigner: "server" }, + }); + }); + + it("falls back to the client-signed accept through its own creation-failure hook", async () => { + const client = new BatchSvmScheme(payer, { discoverChannels: false }); + const server = serverAccept(); + const fallback = clientAccept(); + const context = { maxAmountPerPayment: "1000" }; + const paymentRequired = { + accepts: [server, fallback], + resource: { url: `${ORIGIN}/v1/infer`, description: "", mimeType: "" }, + x402Version: 2, + }; + const error = await client.createPaymentPayload(2, server, context).catch(e => e as Error); + expect(error).toBeInstanceOf(UntrustedOperatorError); + const recovered = await client.schemeHooks.onPaymentCreationFailure!({ + error, + paymentRequired, + selectedRequirements: server, + } as never); + expect(recovered).toMatchObject({ + recovered: true, + payload: { + accepted: fallback, + payload: { type: "deposit", channelConfig: { payerAuthorizer: payer.address } }, + resource: paymentRequired.resource, + x402Version: 2, + }, + }); + // The client-signed twin is paid with the spend-cap context core resolved + // for the refused accept: 5 × 1000 default multiplier is the ceiling. + expect( + (recovered as { payload: { payload: { deposit: { amount: string } } } }).payload.payload, + ).toMatchObject({ deposit: { amount: "5000" } }); + + // No fallback when the alternatives are on another asset, cost more, or + // are server-signed too; and other errors are left alone. + const dearer = clientAccept({ amount: "2000" }); + for (const accepts of [ + [server], + [server, serverAccept(otherOperator.address)], + [server, dearer], + ]) { + await expect( + client.schemeHooks.onPaymentCreationFailure!({ + error, + paymentRequired: { ...paymentRequired, accepts }, + selectedRequirements: server, + } as never), + ).resolves.toBeUndefined(); + } + await expect( + client.schemeHooks.onPaymentCreationFailure!({ + error: new Error("rpc down"), + paymentRequired, + selectedRequirements: server, + } as never), + ).resolves.toBeUndefined(); + }); + + it("caps the escrow at the grant, ignoring larger server hints and fixed deposits", async () => { + // $0.0025 in USDC (6 decimals) = 2500 atomic. + const trust = { allowedOperators: [operator.address], maxDeposit: "$0.0025" }; + const hinted = serverAccept(operator.address, { minDeposit: "100000" }); + const client = new BatchSvmScheme(payer, { + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }); + await expect(client.createPaymentPayload(2, hinted)).resolves.toMatchObject({ + payload: { deposit: { amount: "2500" } }, + }); + const fixed = new BatchSvmScheme(payer, { + depositAmount: 50_000n, + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }); + await expect(fixed.createPaymentPayload(2, hinted)).resolves.toMatchObject({ + payload: { deposit: { amount: "2500" } }, + }); + // The first request alone would already exceed what the client is willing + // to hand the operator. + await expect( + new BatchSvmScheme(payer, { + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }).createPaymentPayload(2, { ...serverAccept(), amount: "3000" }), + ).rejects.toThrow(/exceeds the remaining serverSignedChannelsPolicy maxDeposit/); + }); + + it("refuses a top-up that would push the escrow past the grant", async () => { + const client = new BatchSvmScheme(payer, { + discoverChannels: false, + serverSignedChannelsPolicy: { allowedOperators: [operator.address], maxDeposit: "$0.0025" }, + }); + const accept = serverAccept(); + const opened = await client.createPaymentPayload(2, accept); + const channelId = opened.payload.authorization!.channelId; + const settle = async (payment: typeof opened, cumulative: bigint) => + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: accept, ...payment }, + requirements: accept, + settleResponse: { + extra: { + channelState: { chargedCumulativeAmount: cumulative.toString() }, + commitmentId: `${channelId}:${cumulative}`, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: cumulative.toString(), + signature: await signVoucher(operator, { + channelId, + cumulativeAmount: cumulative, + expiresAt: 0n, + }), + }, + }, + success: true, + }, + } as never); + await settle(opened, 1_000n); + await settle(await client.createPaymentPayload(2, accept), 2_000n); + // 2000 charged of 2500 escrowed; the next 1000 ceiling needs 500 more, + // and the grant has no room left. + await expect(client.createPaymentPayload(2, accept)).rejects.toThrow( + /2500 total, 2500 already escrowed/, + ); + }); + + it("refunds server-signed channels with payer authorization only", async () => { + const accept = serverAccept(); + const client = new BatchSvmScheme(payer, { + depositAmount: 10_000n, + discoverChannels: false, + serverSignedChannelsPolicy: { allowedOperators: [operator.address] }, + }); + const opened = await client.createPaymentPayload(2, accept); + const channelId = opened.payload.authorization!.channelId; + const settle = async (payment: typeof opened, cumulative: bigint) => + client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: accept, ...payment }, + requirements: accept, + settleResponse: { + extra: { + channelState: { chargedCumulativeAmount: cumulative.toString() }, + commitmentId: `${channelId}:${cumulative}`, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: cumulative.toString(), + signature: await signVoucher(operator, { + channelId, + cumulativeAmount: cumulative, + expiresAt: 0n, + }), + }, + }, + success: true, + }, + } as never); + + await settle(opened, 1_000n); + await settle(await client.createPaymentPayload(2, accept), 2_000n); + + const refund = await client.createRefundPayload(2, accept); + expect(refund.payload).toMatchObject({ + type: "refund", + authorization: { authorizedAmount: "0", channelId }, + }); + expect(refund.payload).not.toHaveProperty("voucher"); + }); + + it("refunds a server-signed channel when the probe returns a client-signed accept", async () => { + const accept = serverAccept(); + const client = new BatchSvmScheme(payer, { + depositAmount: 10_000n, + discoverChannels: false, + serverSignedChannelsPolicy: { allowedOperators: [operator.address] }, + }); + const opened = await client.createPaymentPayload(2, accept); + const channelId = opened.payload.authorization!.channelId; + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: accept, ...opened }, + requirements: accept, + settleResponse: { + extra: { + channelState: { chargedCumulativeAmount: "1000" }, + commitmentId: `${channelId}:1000`, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "1000", + signature: await signVoucher(operator, { + channelId, + cumulativeAmount: 1_000n, + expiresAt: 0n, + }), + }, + }, + success: true, + }, + } as never); + + const refund = await client.createRefundPayload(2, clientAccept()); + expect(refund.payload).toMatchObject({ + type: "refund", + authorization: { authorizedAmount: "0", channelId }, + }); + expect(refund.payload).not.toHaveProperty("voucher"); + }); + + it("adopts a corrective 402 only up to what this client authorized", async () => { + const trust = { allowedOperators: [operator.address] }; + const accept = serverAccept(); + const run = async (correctiveCumulative: bigint) => { + const client = new BatchSvmScheme(payer, { + depositAmount: 10_000n, + discoverChannels: false, + serverSignedChannelsPolicy: trust, + }); + const opened = await client.createPaymentPayload(2, accept); + const channelId = opened.payload.authorization!.channelId; + const sign = async (cumulative: bigint) => + signVoucher(operator, { channelId, cumulativeAmount: cumulative, expiresAt: 0n }); + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: accept, ...opened }, + requirements: accept, + settleResponse: { + extra: { + channelState: { chargedCumulativeAmount: "1000" }, + commitmentId: `${channelId}:1000`, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "1000", + signature: await sign(1_000n), + }, + }, + success: true, + }, + } as never); + const next = await client.createPaymentPayload(2, accept); + const recovered = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: accept, ...next }, + requirements: accept, + settleResponse: { success: false, errorReason: BatchError.CUMULATIVE_AMOUNT_MISMATCH }, + paymentRequired: { + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + accepts: [ + { + ...accept, + extra: { + ...accept.extra, + channelState: { + balance: "10000", + channelId, + chargedCumulativeAmount: correctiveCumulative.toString(), + totalClaimed: "0", + withdrawRequestedAt: 0, + }, + voucherState: { + expiresAt: 0, + signature: await sign(correctiveCumulative), + signedMaxClaimable: correctiveCumulative.toString(), + }, + }, + }, + ], + }, + } as never); + return recovered; + }; + // Confirmed 1000 plus one unresolved 1000 ceiling: 2000 is the most the + // operator can legitimately say it charged. + await expect(run(2_000n)).resolves.toEqual({ recovered: true }); + // A validly operator-signed jump to 5000 is refused; the signature is the + // operator's own and proves nothing about what the client authorized. + await expect(run(5_000n)).resolves.toBeUndefined(); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.closeAuthorization.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.closeAuthorization.test.ts new file mode 100644 index 0000000000..29697b9b0c --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.closeAuthorization.test.ts @@ -0,0 +1,102 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + encodeCloseAuthorizationDigest, + signCloseAuthorization, + verifyCloseAuthorization, +} from "../../src/batch-settlement/closeAuthorization"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +let authorizer: Awaited>; +let other: Awaited>; + +const NOW = 1_800_000_000; + +beforeAll(async () => { + authorizer = await generateKeyPairSigner(); + other = await generateKeyPairSigner(); +}); + +function binding(validBefore = NOW + 120) { + return { + channelId: USDC_MAINNET_ADDRESS, + feePayer: USDC_DEVNET_ADDRESS, + maxClaimableAmount: 5_000n, + network: SOLANA_DEVNET_CAIP2, + validBefore, + voucherExpiresAt: 0n, + }; +} + +describe("batch-settlement close authorization", () => { + it("hashes the domain-separated message to 32 bytes and binds every field", () => { + const base = encodeCloseAuthorizationDigest(binding()); + expect(base.byteLength).toBe(32); + const variants = [ + { ...binding(), channelId: USDC_DEVNET_ADDRESS }, + { ...binding(), feePayer: USDC_MAINNET_ADDRESS }, + { ...binding(), maxClaimableAmount: 5_001n }, + { ...binding(), voucherExpiresAt: 1n }, + { ...binding(), validBefore: NOW + 121 }, + { ...binding(), network: "solana:other" }, + ]; + for (const variant of variants) { + expect(encodeCloseAuthorizationDigest(variant)).not.toEqual(base); + } + }); + + it("rejects malformed bindings", () => { + expect(() => encodeCloseAuthorizationDigest({ ...binding(), channelId: "short" })).toThrow( + /32 bytes/, + ); + expect(() => encodeCloseAuthorizationDigest({ ...binding(), validBefore: 0 })).toThrow( + /validBefore/, + ); + expect(() => encodeCloseAuthorizationDigest({ ...binding(), maxClaimableAmount: -1n })).toThrow( + /u64/, + ); + }); + + it("signs and verifies within the validity window only", async () => { + const authorization = await signCloseAuthorization(authorizer, binding()); + const { validBefore: _omitted, ...fields } = binding(); + void _omitted; + expect(authorization.validBefore).toBe(NOW + 120); + await expect( + verifyCloseAuthorization(authorization, fields, authorizer.address, 300, NOW), + ).resolves.toBe(true); + // Signed by someone other than the trusted receiver authorizer. + await expect( + verifyCloseAuthorization(authorization, fields, other.address, 300, NOW), + ).resolves.toBe(false); + // Bound to a different final voucher. + await expect( + verifyCloseAuthorization( + authorization, + { ...fields, maxClaimableAmount: 4_999n }, + authorizer.address, + 300, + NOW, + ), + ).resolves.toBe(false); + // Expired, and further out than maxTimeoutSeconds allows. + await expect( + verifyCloseAuthorization(authorization, fields, authorizer.address, 300, NOW + 120), + ).resolves.toBe(false); + await expect( + verifyCloseAuthorization(authorization, fields, authorizer.address, 60, NOW), + ).resolves.toBe(false); + // Garbage signature never throws. + await expect( + verifyCloseAuthorization( + { ...authorization, signature: "nope" }, + fields, + authorizer.address, + 300, + NOW, + ), + ).resolves.toBe(false); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.delegatedAuth.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.delegatedAuth.test.ts new file mode 100644 index 0000000000..d93e41c0e0 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.delegatedAuth.test.ts @@ -0,0 +1,374 @@ +import { address, generateKeyPairSigner, type Signature } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { buildDepositPayload } from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { + BatchDelegatedAuthIdentityConflictError, + InMemoryBatchDelegatedAuthStore, +} from "../../src/batch-settlement/facilitator/delegatedAuthStore"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import type { + BatchChannelConfig, + BatchRefundPayload, + BatchSealPayload, +} from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import { getChannelDistributionHash } from "../../src/payment-channels/facilitator"; +import { ChannelStatus } from "../../src/payment-channels/onchain"; +import { signVoucher } from "../../src/payment-channels/voucher"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const SIGNATURE = USDC_DEVNET_ADDRESS as Signature; +const NOW = 1_800_000_000; +const CALLER = "service-a"; + +let payer: Awaited>; +let feePayer: Awaited>; +let server: Awaited>; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + server = await generateKeyPairSigner(); +}); + +function requirements(receiverAuthorizer = server.address): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function signer() { + return { + confirmTransaction: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_PROGRAM_ADDRESS }), + getAddresses: () => [feePayer.address], + getLatestBlockhash: vi.fn(), + getSigner: () => feePayer, + getSlot: vi.fn(), + sendTransaction: vi.fn().mockResolvedValue(SIGNATURE), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + }; +} + +function channel(channelConfig: BatchChannelConfig, overrides: Partial = {}): Channel { + return { + authorizedSigner: address(payer.address), + bump: 1, + closureStartedAt: BigInt(NOW - 60), + deposit: 10_000n, + discriminator: 1, + distributionHash: getChannelDistributionHash([{ bps: 10_000, recipient: RECEIVER }]), + gracePeriod: 900, + mint: address(MINT), + openSlot: BigInt(channelConfig.openSlot), + payee: address(feePayer.address), + payer: address(payer.address), + payerWithdrawnAt: 0n, + rentPayer: address(feePayer.address), + salt: BigInt(channelConfig.salt), + settlement: { payoutWatermark: 0n, settled: 0n }, + status: ChannelStatus.Closing, + version: 1, + ...overrides, + }; +} + +function delegatedScheme(identity: string | undefined, options: { delegate?: boolean } = {}) { + const identities = new InMemoryBatchDelegatedAuthStore(); + const bindings = new InMemoryBatchReceiverAuthorizerStore(); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: bindings, + ...(options.delegate === false + ? {} + : { + delegatedReceiverAuth: { + identityStore: identities, + receiverAuthorizer: server.address, + resolveCallerIdentity: () => identity, + }, + }), + }); + return { bindings, identities, scheme }; +} + +describe("batch-settlement delegated receiver authorization", () => { + it("advertises the delegated key and binds the caller on open", async () => { + const { identities, scheme } = delegatedScheme(CALLER); + expect(scheme.getExtra(NETWORK)).toMatchObject({ receiverAuthorizer: server.address }); + + const opened = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 123n, + payer, + receiver: RECEIVER, + receiverAuthorizer: server.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const api = scheme as unknown as Record>; + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.fetchChannel = vi + .fn() + .mockResolvedValue( + channel(opened.payload.channelConfig, { closureStartedAt: 0n, status: ChannelStatus.Open }), + ); + api.broadcastDurably = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + api.completeOrPending = vi.fn().mockResolvedValue(undefined); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + + const openedResult = await scheme.settle( + { accepted: requirements(), payload: opened.payload, x402Version: 2 }, + requirements(), + ); + expect(openedResult.success, JSON.stringify(openedResult)).toBe(true); + expect(await identities.get(NETWORK, opened.channelId)).toMatchObject({ + callerIdentity: CALLER, + }); + }); + + it("rejects a delegated open with no caller identity", async () => { + const { identities, scheme } = delegatedScheme(undefined); + const opened = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 124n, + payer, + receiver: RECEIVER, + receiverAuthorizer: server.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const api = scheme as unknown as Record>; + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.broadcastDurably = vi.fn(); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + + await expect( + scheme.settle( + { accepted: requirements(), payload: opened.payload, x402Version: 2 }, + requirements(), + ), + ).resolves.toMatchObject({ + errorReason: BatchError.DELEGATED_UNAUTHENTICATED, + success: false, + }); + expect(api.broadcastDurably).not.toHaveBeenCalled(); + expect(await identities.get(NETWORK, opened.channelId)).toBeUndefined(); + }); + + it("seals and refunds a matching caller without closeAuthorization", async () => { + const opened = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 125n, + payer, + receiver: RECEIVER, + receiverAuthorizer: server.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const { bindings, identities, scheme } = delegatedScheme(CALLER); + await bindings.bind({ + channelId: opened.channelId, + network: NETWORK, + receiverAuthorizer: server.address, + }); + await identities.bind({ + callerIdentity: CALLER, + channelId: opened.channelId, + network: NETWORK, + }); + const live = channel(opened.payload.channelConfig); + stubClose(scheme, opened.channelId, live); + + const voucher = await signedVoucher(opened.channelId, 1_000n); + const seal: BatchSealPayload = { + channelConfig: opened.payload.channelConfig, + channelId: opened.channelId, + type: "seal", + voucher, + }; + await expect( + scheme.settle({ accepted: requirements(), payload: seal, x402Version: 2 }, requirements()), + ).resolves.toMatchObject({ + success: true, + transaction: SIGNATURE, + }); + + stubClose( + scheme, + opened.channelId, + channel(opened.payload.channelConfig, { status: ChannelStatus.Open }), + ); + const refund: BatchRefundPayload = { + channelConfig: opened.payload.channelConfig, + type: "refund", + voucher, + }; + await expect( + scheme.settle({ accepted: requirements(), payload: refund, x402Version: 2 }, requirements()), + ).resolves.toMatchObject({ success: true }); + }); + + it("rejects a delegated close whose caller does not match, and a facilitator that does not delegate", async () => { + const opened = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 126n, + payer, + receiver: RECEIVER, + receiverAuthorizer: server.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const mismatched = delegatedScheme("someone-else"); + await mismatched.bindings.bind({ + channelId: opened.channelId, + network: NETWORK, + receiverAuthorizer: server.address, + }); + await mismatched.identities.bind({ + callerIdentity: CALLER, + channelId: opened.channelId, + network: NETWORK, + }); + stubClose(mismatched.scheme, opened.channelId, channel(opened.payload.channelConfig)); + const voucher = await signedVoucher(opened.channelId, 1_000n); + const seal: BatchSealPayload = { + channelConfig: opened.payload.channelConfig, + channelId: opened.channelId, + type: "seal", + voucher, + }; + await expect( + mismatched.scheme.settle( + { accepted: requirements(), payload: seal, x402Version: 2 }, + requirements(), + ), + ).resolves.toMatchObject({ + errorReason: BatchError.DELEGATED_UNAUTHENTICATED, + success: false, + }); + + const plain = delegatedScheme(CALLER, { delegate: false }); + await plain.bindings.bind({ + channelId: opened.channelId, + network: NETWORK, + receiverAuthorizer: server.address, + }); + stubClose(plain.scheme, opened.channelId, channel(opened.payload.channelConfig)); + await expect( + plain.scheme.settle( + { accepted: requirements(), payload: seal, x402Version: 2 }, + requirements(), + ), + ).resolves.toMatchObject({ + errorReason: BatchError.CLOSE_AUTHORIZATION, + success: false, + }); + }); + + it("InMemoryBatchDelegatedAuthStore bind is first-writer-wins", async () => { + const store = new InMemoryBatchDelegatedAuthStore(); + const binding = { + callerIdentity: CALLER, + channelId: payer.address, + network: NETWORK, + }; + + await store.bind(binding); + await store.bind(binding); + + await expect(store.bind({ ...binding, callerIdentity: "other" })).rejects.toBeInstanceOf( + BatchDelegatedAuthIdentityConflictError, + ); + expect(await store.get(NETWORK, payer.address)).toMatchObject({ callerIdentity: CALLER }); + await store.delete(NETWORK, payer.address); + expect(await store.get(NETWORK, payer.address)).toBeUndefined(); + }); + + it("drops the caller identity when rent cleanup deletes the channel", async () => { + const { identities, scheme } = delegatedScheme(CALLER); + const channelId = payer.address; + await identities.bind({ callerIdentity: CALLER, channelId, network: NETWORK }); + const manager = scheme.createRentCleanupManager(NETWORK); + await (manager as unknown as { storage: { delete(id: string): Promise } }).storage.delete( + channelId, + ); + expect(await identities.get(NETWORK, channelId)).toBeUndefined(); + }); +}); + +function stubClose(scheme: BatchSvmScheme, channelId: string, live: Channel): void { + const api = scheme as unknown as { + deriveChannelId: ReturnType; + distributeInstruction: ReturnType; + fetchChannel: ReturnType; + readChannel: ReturnType; + sealDependencies(): { nowSeconds(): number }; + submitRedemption: ReturnType; + trackChannel: ReturnType; + }; + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.fetchChannel = vi.fn().mockResolvedValue(live); + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.distributeInstruction = vi.fn().mockResolvedValue({ + accounts: [], + data: new Uint8Array([9]), + programAddress: address(RECEIVER), + }); + api.submitRedemption = vi.fn().mockResolvedValue({ + ok: true, + replayed: false, + signature: SIGNATURE, + }); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + const original = api.sealDependencies.bind(scheme); + api.sealDependencies = () => ({ ...original(), nowSeconds: () => NOW }); +} + +async function signedVoucher(channelId: string, cumulative: bigint) { + return { + channelId, + expiresAt: 0, + maxClaimableAmount: cumulative.toString(), + signature: await signVoucher(payer, { channelId, cumulativeAmount: cumulative, expiresAt: 0n }), + }; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.facilitator.lifecycle.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.facilitator.lifecycle.test.ts new file mode 100644 index 0000000000..2bd340e995 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.facilitator.lifecycle.test.ts @@ -0,0 +1,1755 @@ +import { findAssociatedTokenPda } from "@solana-program/token-2022"; +import { address, generateKeyPairSigner, type Signature } from "@solana/kit"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { signBatchAuthorization } from "../../src/batch-settlement/authorization"; +import { + buildDepositPayload, + buildRefundPayload, + signBatchVoucher, +} from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { MAX_CHANNELS_PER_SETTLE_TX } from "../../src/batch-settlement/facilitator/constants"; +import { + BatchSvmScheme, + calculateDistributionAmount, +} from "../../src/batch-settlement/facilitator/scheme"; +import type { + BatchChannelConfig, + BatchClaimPayload, + BatchSettlePayload, +} from "../../src/batch-settlement/types"; +import { + SOLANA_DEVNET_CAIP2, + TOKEN_2022_PROGRAM_ADDRESS, + TOKEN_PROGRAM_ADDRESS, +} from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import { ChannelStatus } from "../../src/payment-channels/onchain"; +import { getChannelDistributionHash } from "../../src/payment-channels/facilitator"; +import { ChannelBroadcastConfirmationError } from "../../src/payment-channels/facilitator"; +import { TransactionOnchainFailureError } from "../../src/utils"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const SIGNATURE = USDC_DEVNET_ADDRESS as Signature; + +let payer: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; +let channelId: string; +let channelConfig: BatchChannelConfig; +let actualChannelId: string; +let actualDeposit: Awaited>["payload"]; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + channelId = USDC_MAINNET_ADDRESS; + channelConfig = { + openSlot: 1, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; + const built = await buildDepositPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 123n, + payer, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + actualChannelId = built.channelId; + actualDeposit = built.payload; +}); + +function requirements(overrides: Partial = {}): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + ...overrides, + }; +} + +function channel(overrides: Partial = {}): Channel { + return { + authorizedSigner: address(payer.address), + bump: 1, + closureStartedAt: 0n, + deposit: 10_000n, + discriminator: 1, + distributionHash: getChannelDistributionHash([{ bps: 10_000, recipient: RECEIVER }]), + gracePeriod: 900, + mint: address(MINT), + openSlot: 1n, + payee: address(feePayer.address), + payer: address(payer.address), + payerWithdrawnAt: 0n, + rentPayer: address(feePayer.address), + salt: 0n, + settlement: { payoutWatermark: 0n, settled: 0n }, + status: ChannelStatus.Open, + version: 1, + ...overrides, + }; +} + +function signer(overrides: Record = {}) { + return { + confirmTransaction: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_PROGRAM_ADDRESS }), + getConfirmedTransaction: vi.fn(() => payoutEvidence()), + getAddresses: vi.fn(() => [feePayer.address]), + getSigner: vi.fn(() => feePayer), + sendTransaction: vi.fn().mockResolvedValue(SIGNATURE), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +type FacilitatorInternals = { + resolveTerms( + config: BatchChannelConfig, + requirements: PaymentRequirements, + binding?: "requirements" | "payload", + ): Promise<{ + feePayer: string; + feePayerSigner: typeof feePayer; + tokenProgram: string; + voucherSigner: "client" | "server"; + withdrawDelay: number; + }>; + deriveChannelId: ReturnType; + fetchChannel: ReturnType; + fetchChannelUntil: ReturnType; + fetchChannelsUntil: ReturnType; + trackChannel: ReturnType; + submitRedemption: ReturnType; + distributeInstruction: ReturnType; + broadcastDurably( + key: string, + network: typeof NETWORK, + payerAddress: string, + broadcast: (onBroadcast: (signature: string) => Promise) => Promise, + ): Promise; + validateDeposit: ReturnType; + validateVoucherOnly: ReturnType; + settleDeposit( + payment: PaymentPayload, + payload: unknown, + requirements: PaymentRequirements, + ): Promise; + settleRefund( + payment: PaymentPayload, + payload: unknown, + requirements: PaymentRequirements, + ): Promise; + settleVoucher( + payment: PaymentPayload, + payload: unknown, + requirements: PaymentRequirements, + ): Promise; + reconcileBroadcast( + key: string, + signature: string, + network: typeof NETWORK, + payer: string, + ): Promise; + assertExpiry(expiresAt: number): void; + assertClaimChannel: ReturnType; + assertServerModeProof( + payload: unknown, + channelId: string, + requirements: PaymentRequirements, + proofBound?: "exact" | "ceiling", + ): Promise; + assertSettlementAccounts( + requirements: PaymentRequirements, + payer: string, + tokenProgram: string, + ): Promise; + readChannel: ReturnType; + settlementCache: { + delete: ReturnType; + isDuplicate: ReturnType; + }; +}; + +function internals(scheme: BatchSvmScheme): FacilitatorInternals { + return scheme as unknown as FacilitatorInternals; +} + +describe("batch facilitator lifecycle", () => { + it("requires a usable managed signer", () => { + expect( + () => + new BatchSvmScheme({ getAddresses: () => [feePayer.address] } as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ).toThrow(/requires getSigner/); + expect( + () => + new BatchSvmScheme({ getAddresses: () => [], getSigner: vi.fn() } as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ).toThrow(/at least one fee payer/); + }); + + it.each([ + ["payer", 0], + ["recipient", 1], + ["payment-channel treasury", 2], + ])("identifies a missing %s settlement ATA", async (label, missingIndex) => { + let readIndex = 0; + const getAccountInfo = vi.fn().mockImplementation(async () => { + const exists = readIndex !== missingIndex; + readIndex += 1; + return exists ? { owner: TOKEN_PROGRAM_ADDRESS } : null; + }); + const api = internals( + new BatchSvmScheme(signer({ getAccountInfo }) as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + + await expect( + api.assertSettlementAccounts(requirements(), payer.address, TOKEN_PROGRAM_ADDRESS), + ).rejects.toThrow(`missing ${label} ATA`); + expect(getAccountInfo).toHaveBeenCalledTimes(missingIndex + 1); + }); + + it("identifies a settlement ATA owned by the wrong token program", async () => { + const api = internals( + new BatchSvmScheme( + signer({ + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_2022_PROGRAM_ADDRESS }), + }) as never, + { receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore() }, + ), + ); + + await expect( + api.assertSettlementAccounts(requirements(), payer.address, TOKEN_PROGRAM_ADDRESS), + ).rejects.toThrow(`payer ATA is not owned by ${TOKEN_PROGRAM_ADDRESS}`); + }); + + it("requires account reads for settlement-path preflight", async () => { + const api = internals( + new BatchSvmScheme(signer({ getAccountInfo: undefined }) as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ); + + await expect( + api.assertSettlementAccounts(requirements(), payer.address, TOKEN_PROGRAM_ADDRESS), + ).rejects.toThrow("requires getAccountInfo"); + }); + + it("rejects a deposit during verify when its settlement path is unavailable", async () => { + const getAccountInfo = vi + .fn() + .mockResolvedValueOnce({ owner: TOKEN_PROGRAM_ADDRESS }) + .mockResolvedValueOnce(null); + const scheme = new BatchSvmScheme(signer({ getAccountInfo }) as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const paymentRequirements = requirements(); + + await expect( + scheme.verify( + { + accepted: paymentRequirements, + payload: actualDeposit, + x402Version: 2, + }, + paymentRequirements, + ), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: BatchError.SETTLEMENT_SIMULATION, + }); + }); + + it("resolves valid channel terms and rejects malformed requirements", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + await expect( + internals(scheme).resolveTerms(channelConfig, requirements()), + ).resolves.toMatchObject({ + feePayer: feePayer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + + await expect( + internals(scheme).resolveTerms(channelConfig, requirements({ extra: undefined })), + ).rejects.toThrow(BatchError.PAYMENT_FLOW); + await expect( + internals(scheme).resolveTerms(channelConfig, { + ...requirements(), + extra: { ...requirements().extra, feePayer: undefined }, + }), + ).rejects.toThrow(BatchError.FEE_PAYER_MISMATCH); + await expect( + internals(scheme).resolveTerms(channelConfig, { + ...requirements(), + extra: { ...requirements().extra, withdrawDelay: 10 }, + }), + ).rejects.toThrow(BatchError.WITHDRAW_DELAY_OUT_OF_RANGE); + await expect( + internals(scheme).resolveTerms({ ...channelConfig, receiver: payer.address }, requirements()), + ).rejects.toThrow(BatchError.CHANNEL_STATE); + await expect( + internals(scheme).resolveTerms(channelConfig, { + ...requirements(), + extra: { ...requirements().extra, tokenProgram: payer.address }, + }), + ).rejects.toThrow(BatchError.TOKEN_PROGRAM); + }); + + it("covers every facilitator term and channel-binding boundary", async () => { + const resolve = (scheme: BatchSvmScheme, config = channelConfig, req = requirements()) => + internals(scheme).resolveTerms(config, req); + const validSigner = signer(); + const valid = new BatchSvmScheme(validSigner as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const token2022 = new BatchSvmScheme( + signer({ + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_2022_PROGRAM_ADDRESS }), + }) as never, + { receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore() }, + ); + const receiverAuthorizer = payer.address; + await expect( + resolve( + token2022, + { ...channelConfig, receiverAuthorizer }, + { + ...requirements(), + extra: { + ...requirements().extra, + memo: "invoice", + receiverAuthorizer, + tokenProgram: TOKEN_2022_PROGRAM_ADDRESS, + }, + }, + ), + ).resolves.toMatchObject({ memo: "invoice", receiverAuthorizer }); + + const cases: Array<[BatchChannelConfig, PaymentRequirements]> = [ + [ + channelConfig, + { ...requirements(), extra: { ...requirements().extra, paymentFlow: "upfront" } }, + ], + [{ ...channelConfig, payer: feePayer.address }, requirements()], + [{ ...channelConfig, payerAuthorizer: feePayer.address }, requirements()], + [ + channelConfig, + { ...requirements(), extra: { ...requirements().extra, withdrawDelay: 900.5 } }, + ], + [ + channelConfig, + { ...requirements(), extra: { ...requirements().extra, withdrawDelay: 2_592_001 } }, + ], + [channelConfig, { ...requirements(), maxTimeoutSeconds: 901 }], + [{ ...channelConfig, withdrawDelay: 901 }, requirements()], + [{ ...channelConfig, receiverAuthorizer }, requirements()], + [ + channelConfig, + { ...requirements(), extra: { ...requirements().extra, receiverAuthorizer } }, + ], + [ + { ...channelConfig, receiverAuthorizer }, + { + ...requirements(), + extra: { ...requirements().extra, receiverAuthorizer: feePayer.address }, + }, + ], + [channelConfig, { ...requirements(), extra: { ...requirements().extra, memo: 3 } }], + ]; + for (const [config, req] of cases) await expect(resolve(valid, config, req)).rejects.toThrow(); + + const noAccountRead = new BatchSvmScheme(signer({ getAccountInfo: undefined }) as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + await expect(resolve(noAccountRead)).rejects.toThrow(/requires getAccountInfo/); + const missingMint = new BatchSvmScheme( + signer({ getAccountInfo: vi.fn().mockResolvedValue(undefined) }) as never, + { receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore() }, + ); + await expect(resolve(missingMint)).rejects.toThrow(BatchError.TOKEN_PROGRAM); + + const terms = { + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }; + const assert = (value: Channel, config = channelConfig, allowed = [ChannelStatus.Open]) => + internals( + new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }), + ).assertClaimChannel(value, config, terms, requirements(), allowed); + const mutations: Channel[] = [ + channel({ discriminator: 0 }), + channel({ status: ChannelStatus.Closing }), + channel({ payer: address(feePayer.address) }), + channel({ payee: address(payer.address) }), + channel({ rentPayer: address(payer.address) }), + channel({ authorizedSigner: address(feePayer.address) }), + channel({ mint: address(RECEIVER) }), + channel({ gracePeriod: 901 }), + channel({ salt: 1n }), + channel({ openSlot: 2n }), + channel({ distributionHash: new Uint8Array() }), + channel({ distributionHash: new Uint8Array(32).fill(1) }), + ]; + for (const value of mutations) expect(() => assert(value)).toThrow(BatchError.CHANNEL_STATE); + expect(() => assert(channel())).not.toThrow(); + }); + + it("verifies scheme and network envelopes before channel reads", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const voucher = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }); + const payment = { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher }, + x402Version: 2, + } as PaymentPayload; + await expect( + scheme.verify(payment, { ...requirements(), scheme: "exact" }), + ).resolves.toMatchObject({ isValid: false, invalidReason: "unsupported_scheme" }); + await expect( + scheme.verify(payment, { ...requirements(), network: "solana:other" }), + ).resolves.toMatchObject({ isValid: false, invalidReason: "network_mismatch" }); + }); + + it("routes verify variants and classifies validation failures", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + const voucher = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }); + const deposit = { + channelConfig, + deposit: { amount: "10000", transaction: "setup" }, + type: "deposit" as const, + voucher, + }; + api.validateDeposit = vi.fn().mockResolvedValue({ channelId }); + await expect( + scheme.verify({ accepted: requirements(), payload: deposit, x402Version: 2 }, requirements()), + ).resolves.toMatchObject({ isValid: true, extra: { channelId } }); + + api.resolveTerms = vi.fn().mockResolvedValue({ feePayer: feePayer.address }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.validateVoucherOnly = vi.fn().mockResolvedValue(channel()); + await expect( + scheme.verify( + { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher }, + x402Version: 2, + }, + requirements(), + ), + ).resolves.toMatchObject({ isValid: true, extra: { channelId } }); + + api.deriveChannelId = vi.fn().mockResolvedValue(payer.address); + await expect( + scheme.verify( + { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher }, + x402Version: 2, + }, + requirements(), + ), + ).resolves.toMatchObject({ isValid: false, invalidReason: BatchError.CHANNEL_ID_MISMATCH }); + + api.validateDeposit = vi.fn().mockRejectedValue(new Error(BatchError.VOUCHER_SIGNATURE)); + await expect( + scheme.verify({ accepted: requirements(), payload: deposit, x402Version: 2 }, requirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: BatchError.VOUCHER_SIGNATURE, + invalidMessage: BatchError.VOUCHER_SIGNATURE, + }); + api.validateDeposit = vi.fn().mockRejectedValue("plain validation failure"); + await expect( + scheme.verify({ accepted: requirements(), payload: deposit, x402Version: 2 }, requirements()), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: "transaction_failed", + invalidMessage: "plain validation failure", + }); + await expect( + scheme.verify( + { accepted: requirements(), payload: { nope: true }, x402Version: 2 } as never, + requirements(), + ), + ).resolves.toMatchObject({ isValid: false, invalidReason: BatchError.PAYLOAD_TYPE }); + }); + + it("verifies the payer proof behind server-mode payloads", async () => { + const operator = await generateKeyPairSigner(); + const store = new InMemoryBatchReceiverAuthorizerStore(); + await store.bind({ + channelId, + network: NETWORK, + receiverAuthorizer: receiverAuthorizer.address, + }); + const scheme = new BatchSvmScheme(signer() as never, { receiverAuthorizerStore: store }); + const api = internals(scheme); + const serverConfig: BatchChannelConfig = { + ...channelConfig, + payerAuthorizer: operator.address, + voucherSigner: "server", + }; + const serverRequirements = requirements({ + extra: { + ...requirements().extra, + operator: operator.address, + voucherSigner: "server", + }, + }); + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.fetchChannel = vi + .fn() + .mockResolvedValue(channel({ authorizedSigner: address(operator.address) })); + const expiresAt = Math.floor(Date.now() / 1000) + 600; + const proof = await signBatchAuthorization( + payer, + channelId, + operator.address, + "request-1", + 1_000n, + expiresAt, + ); + const verify = (authorization: unknown) => + scheme.verify( + { + accepted: serverRequirements, + payload: { authorization, channelConfig: serverConfig, type: "authorization" }, + x402Version: 2, + } as never, + serverRequirements, + ); + await expect(verify(proof)).resolves.toMatchObject({ + isValid: true, + extra: { channelId }, + }); + // Every binding the resource server relies on is re-checked here, so a + // facilitator used as a standalone verifier cannot be fed a forged, + // re-priced, re-targeted, or expired proof. + const forged = [ + { ...proof, authorizedAmount: "2000" }, + { ...proof, channelId: payer.address }, + { ...proof, payer: feePayer.address }, + { ...proof, requestId: "request-2" }, + { ...proof, expiresAt: expiresAt - 1200 }, + { ...proof, signature: proof.signature.replace(/^./, c => (c === "1" ? "2" : "1")) }, + await signBatchAuthorization( + payer, + channelId, + feePayer.address, + "request-1", + 1_000n, + expiresAt, + ), + ]; + for (const authorization of forged) { + await expect(verify(authorization)).resolves.toMatchObject({ + isValid: false, + invalidReason: BatchError.VOUCHER_SIGNATURE, + }); + } + // Structurally invalid proofs never reach the signature check. + for (const authorization of [{ ...proof, requestId: "" }, undefined]) { + await expect(verify(authorization)).resolves.toMatchObject({ + isValid: false, + invalidReason: BatchError.PAYLOAD_TYPE, + }); + } + // Server-mode deposits carry the same proof and get the same check. + const deposit = { + authorization: proof, + channelConfig: serverConfig, + deposit: { amount: "10000", transaction: "setup" }, + type: "deposit" as const, + }; + await expect( + api.assertServerModeProof(deposit, channelId, serverRequirements), + ).resolves.toBeUndefined(); + await expect( + api.assertServerModeProof( + { ...deposit, authorization: undefined }, + channelId, + serverRequirements, + ), + ).rejects.toThrow(/payer proof missing/); + }); + + it("treats a server-mode payer proof as a ceiling only at settle", async () => { + const operator = await generateKeyPairSigner(); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + const serverConfig: BatchChannelConfig = { + ...channelConfig, + payerAuthorizer: operator.address, + voucherSigner: "server", + }; + const serverRequirements = requirements({ + extra: { + ...requirements().extra, + operator: operator.address, + voucherSigner: "server", + }, + }); + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.readChannel = vi.fn().mockResolvedValue(undefined); + const expiresAt = Math.floor(Date.now() / 1000) + 600; + const proof = await signBatchAuthorization( + payer, + channelId, + operator.address, + "request-1", + 1_000n, + expiresAt, + ); + const deposit = { + authorization: proof, + channelConfig: serverConfig, + deposit: { amount: "10000", transaction: "setup" }, + type: "deposit" as const, + }; + const payment = { accepted: serverRequirements, payload: deposit, x402Version: 2 } as never; + const verify = (amount: string) => scheme.verify(payment, { ...serverRequirements, amount }); + const settle = (amount: string) => scheme.settle(payment, { ...serverRequirements, amount }); + + // Verify still requires the proof to equal the advertised ceiling, even + // when the signature itself is valid for a different amount. + await expect(verify("1000")).resolves.toMatchObject({ + invalidReason: expect.not.stringMatching(BatchError.VOUCHER_SIGNATURE), + isValid: false, + }); + await expect(verify("500")).resolves.toMatchObject({ + invalidReason: BatchError.VOUCHER_SIGNATURE, + isValid: false, + }); + const higher = await signBatchAuthorization( + payer, + channelId, + operator.address, + "request-1", + 2_000n, + expiresAt, + ); + await expect( + scheme.verify( + { ...payment, payload: { ...deposit, authorization: higher } }, + serverRequirements, + ), + ).resolves.toMatchObject({ + invalidReason: BatchError.VOUCHER_SIGNATURE, + isValid: false, + }); + + // Settle accepts a metered charge below the proof and rejects one above it. + await expect(settle("500")).resolves.toMatchObject({ + errorReason: expect.not.stringMatching(BatchError.VOUCHER_SIGNATURE), + success: false, + }); + await expect(settle("1000")).resolves.toMatchObject({ + errorReason: expect.not.stringMatching(BatchError.VOUCHER_SIGNATURE), + success: false, + }); + await expect(settle("1001")).resolves.toMatchObject({ + errorReason: BatchError.VOUCHER_SIGNATURE, + success: false, + }); + + // Client-signed deposits stay fixed-price: a lower settle amount is not a ceiling. + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "client", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(actualChannelId); + await expect( + scheme.settle( + { accepted: requirements(), payload: actualDeposit, x402Version: 2 }, + { ...requirements(), amount: "500" }, + ), + ).resolves.toMatchObject({ + errorReason: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + success: false, + }); + }); + + it("redeems a client-signed channel when the worker requirements are server-signed", async () => { + const operator = await generateKeyPairSigner(); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const serverRequirements = requirements({ + extra: { + ...requirements().extra, + operator: operator.address, + voucherSigner: "server", + }, + }); + const voucher = { + channelId, + expiresAt: 0, + maxClaimableAmount: "1", + signature: "x", + }; + await expect( + internals(scheme).resolveTerms(channelConfig, serverRequirements, "payload"), + ).resolves.toMatchObject({ voucherSigner: "client" }); + await expect(internals(scheme).resolveTerms(channelConfig, serverRequirements)).rejects.toThrow( + BatchError.CHANNEL_STATE, + ); + + const settled = async (payload: unknown) => + scheme.settle( + { accepted: serverRequirements, payload, x402Version: 2 } as never, + serverRequirements, + ); + for (const payload of [ + { + claims: [{ channelConfig, channelId, voucher }], + type: "claim", + }, + { + channels: [{ channelConfig, channelId }], + type: "settle", + }, + { + channelConfig, + channelId, + type: "seal", + voucher, + }, + ]) { + await expect(settled(payload)).resolves.toMatchObject({ + errorReason: expect.not.stringMatching(BatchError.CHANNEL_STATE), + success: false, + }); + } + }); + + it("validates real open, voucher, and refund transactions", async () => { + const store = new InMemoryBatchReceiverAuthorizerStore(); + const scheme = new BatchSvmScheme(signer() as never, { receiverAuthorizerStore: store }); + const api = internals(scheme); + api.readChannel = vi.fn().mockResolvedValue(undefined); + await expect( + scheme.verify( + { accepted: requirements(), payload: actualDeposit, x402Version: 2 }, + requirements(), + ), + ).resolves.toMatchObject({ isValid: true, extra: { channelId: actualChannelId } }); + + await store.bind({ + channelId: actualChannelId, + network: NETWORK, + receiverAuthorizer: receiverAuthorizer.address, + }); + const voucherPayment = { + channelConfig: actualDeposit.channelConfig, + type: "voucher" as const, + voucher: actualDeposit.voucher, + }; + api.assertClaimChannel = vi.fn(); + api.fetchChannel = vi.fn().mockResolvedValue( + channel({ + authorizedSigner: address(payer.address), + openSlot: 123n, + }), + ); + const verifiedVoucher = await scheme.verify( + { accepted: requirements(), payload: voucherPayment, x402Version: 2 }, + requirements(), + ); + expect(verifiedVoucher).toMatchObject({ isValid: true }); + api.fetchChannel = vi + .fn() + .mockResolvedValue( + channel({ authorizedSigner: address(payer.address), deposit: 999n, openSlot: 123n }), + ); + await expect( + scheme.verify( + { accepted: requirements(), payload: voucherPayment, x402Version: 2 }, + requirements(), + ), + ).resolves.toMatchObject({ + isValid: false, + invalidReason: BatchError.CUMULATIVE_EXCEEDS_DEPOSIT, + }); + + const refundArgs = { + channelConfig: actualDeposit.channelConfig, + channelId: actualChannelId, + feePayer: feePayer.address, + payer, + voucher: actualDeposit.voucher, + }; + const refund = await buildRefundPayload(refundArgs); + api.fetchChannel = vi + .fn() + .mockResolvedValue(channel({ authorizedSigner: address(payer.address), openSlot: 123n })); + const verifyRefund = (payload: typeof refund) => + scheme.verify({ accepted: requirements(), payload, x402Version: 2 }, requirements()); + await expect(verifyRefund(refund)).resolves.toMatchObject({ isValid: true }); + + // Without a stored binding only a payer-signed request_close is accepted. + await store.delete(NETWORK, actualChannelId); + await expect(verifyRefund(refund)).resolves.toMatchObject({ + invalidReason: BatchError.RECEIVER_BINDING_UNAVAILABLE, + isValid: false, + }); + const fallback = await buildRefundPayload({ + ...refundArgs, + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + }); + await expect(verifyRefund(fallback)).resolves.toMatchObject({ isValid: true }); + }); + + it("routes facilitator settlement variants and converts thrown errors", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme) as FacilitatorInternals & + Record>; + const cases = [ + ["deposit", "settleDeposit"], + ["voucher", "settleVoucher"], + ["refund", "settleRefund"], + ["claim", "settleClaims"], + ["settle", "settleDistributions"], + ] as const; + for (const [type, method] of cases) { + api[method] = vi.fn().mockResolvedValue({ success: true, transaction: type }); + const payload = + type === "claim" + ? { + claims: [ + { + channelConfig, + channelId, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "1", + signature: "x", + }, + }, + ], + type, + } + : type === "settle" + ? { + channels: [{ channelConfig, channelId }], + type, + } + : type === "refund" + ? { + channelConfig, + type, + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + } + : type === "deposit" + ? { + channelConfig, + deposit: { amount: "1", transaction: "x" }, + type, + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + } + : { + channelConfig, + type, + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + }; + await expect( + scheme.settle( + { accepted: requirements(), payload, x402Version: 2 } as never, + requirements(), + ), + ).resolves.toMatchObject({ success: true, transaction: type }); + } + + api.settleVoucher = vi.fn().mockRejectedValue("rpc down"); + await expect( + scheme.settle( + { + accepted: requirements(), + payload: { + channelConfig, + type: "voucher", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + }, + x402Version: 2, + } as never, + requirements(), + ), + ).resolves.toMatchObject({ success: false, errorReason: "transaction_failed" }); + api.settleVoucher = vi.fn().mockRejectedValue(new Error(BatchError.VOUCHER_SIGNATURE)); + await expect( + scheme.settle( + { + accepted: requirements(), + payload: { + channelConfig, + type: "voucher", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + }, + x402Version: 2, + } as never, + requirements(), + ), + ).resolves.toMatchObject({ + success: false, + errorReason: BatchError.VOUCHER_SIGNATURE, + errorMessage: BatchError.VOUCHER_SIGNATURE, + }); + api.settleVoucher = vi.fn().mockRejectedValue(new Error("duplicate_settlement: channel busy")); + await expect( + scheme.settle( + { + accepted: requirements(), + payload: { + channelConfig, + type: "voucher", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1", signature: "x" }, + }, + x402Version: 2, + } as never, + requirements(), + ), + ).resolves.toMatchObject({ success: false, errorReason: "duplicate_settlement" }); + await expect( + scheme.settle( + { accepted: requirements(), payload: { type: "bad" }, x402Version: 2 } as never, + requirements(), + ), + ).resolves.toMatchObject({ success: false, errorReason: BatchError.PAYLOAD_TYPE }); + }); + + it("settles top-ups, idempotent opens, vouchers, and refunds", async () => { + const facilitatorSigner = signer(); + const store = new InMemoryBatchReceiverAuthorizerStore(); + const scheme = new BatchSvmScheme(facilitatorSigner as never, { + receiverAuthorizerStore: store, + }); + const api = internals(scheme); + const voucher = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }); + const deposit = { + channelConfig, + deposit: { amount: "1000", transaction: "setup" }, + type: "deposit" as const, + voucher, + }; + const terms = { + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }; + api.validateDeposit = vi.fn().mockResolvedValue({ + channelId, + deposit: 1_000n, + expectedDeposit: 11_000n, + isTopUp: true, + payload: deposit, + terms, + }); + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + api.broadcastDurably = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + api.fetchChannel = vi.fn().mockResolvedValue(channel({ deposit: 11_000n })); + api.assertClaimChannel = vi.fn(); + await expect( + api.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + deposit, + requirements(), + ), + ).resolves.toMatchObject({ success: true, amount: "1000", transaction: SIGNATURE }); + expect(facilitatorSigner.simulateTransaction).toHaveBeenCalledWith("setup", NETWORK); + + api.validateDeposit = vi.fn().mockResolvedValue({ + channelId, + deposit: 1_000n, + expectedDeposit: 10_000n, + isTopUp: false, + payload: deposit, + terms, + }); + api.readChannel = vi.fn().mockResolvedValue(channel()); + api.fetchChannel = vi.fn().mockResolvedValue(channel()); + await expect( + api.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + deposit, + requirements(), + ), + ).resolves.toMatchObject({ success: true, transaction: "" }); + + await expect( + api.settleVoucher( + { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher }, + x402Version: 2, + }, + { channelConfig, type: "voucher", voucher }, + requirements(), + ), + ).resolves.toMatchObject({ success: false, errorReason: BatchError.PAYLOAD_TYPE }); + + const refund = { channelConfig, type: "refund" as const, voucher }; + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + await store.bind({ + channelId, + network: NETWORK, + receiverAuthorizer: receiverAuthorizer.address, + }); + // A cooperative refund of a channel already closing reports its state without a broadcast. + api.readChannel = vi + .fn() + .mockResolvedValue(channel({ closureStartedAt: 20n, status: ChannelStatus.Closing })); + await expect( + api.settleRefund( + { accepted: requirements(), payload: refund, x402Version: 2 } as never, + refund, + requirements(), + ), + ).resolves.toMatchObject({ + success: true, + transaction: "", + extra: { channelState: { withdrawRequestedAt: 20 } }, + }); + + // An unbound channel falls back to the payer-signed request_close. + await store.delete(NETWORK, channelId); + const fallback = await buildRefundPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + channelConfig, + channelId, + feePayer: feePayer.address, + payer, + voucher, + }); + api.readChannel = vi.fn().mockResolvedValue(channel()); + api.fetchChannel = vi.fn().mockResolvedValue(channel()); + api.broadcastDurably = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + api.fetchChannelUntil = vi + .fn() + .mockResolvedValue(channel({ closureStartedAt: 20n, status: ChannelStatus.Closing })); + await expect( + api.settleRefund( + { accepted: requirements(), payload: fallback, x402Version: 2 } as never, + fallback, + requirements(), + ), + ).resolves.toMatchObject({ + extra: { channelState: { withdrawRequestedAt: 20 } }, + success: true, + transaction: SIGNATURE, + }); + await expect( + api.settleRefund( + { accepted: requirements(), payload: refund, x402Version: 2 } as never, + refund, + requirements(), + ), + ).rejects.toThrow(BatchError.RECEIVER_BINDING_UNAVAILABLE); + }); + + it("serializes opens by channel and releases the lock before broadcast failures", async () => { + const facilitatorSigner = signer(); + const scheme = new BatchSvmScheme(facilitatorSigner as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + const terms = { + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }; + const deposit = { + channelConfig, + deposit: { amount: "1000", transaction: "open-a" }, + type: "deposit" as const, + voucher: await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }), + }; + api.validateDeposit = vi.fn().mockResolvedValue({ + channelId, + deposit: 1_000n, + expectedDeposit: 1_000n, + isTopUp: false, + payload: deposit, + terms, + }); + api.readChannel = vi.fn().mockResolvedValue(undefined); + const isDuplicate = vi.fn().mockReturnValue(true); + api.settlementCache = { delete: vi.fn(), isDuplicate }; + + for (const transaction of ["open-a", "open-b"]) { + await expect( + api.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + { ...deposit, deposit: { amount: "1000", transaction } }, + requirements(), + ), + ).resolves.toMatchObject({ errorReason: "duplicate_settlement", success: false }); + } + expect(isDuplicate.mock.calls.map(([key]) => key)).toEqual([ + `batch:deposit:${NETWORK}:${channelId}`, + `batch:deposit:${NETWORK}:${channelId}`, + ]); + + const simulation = new BatchSvmScheme( + signer({ + simulateTransaction: vi.fn().mockRejectedValue(new Error("bad simulation")), + }) as never, + { receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore() }, + ); + const simulationApi = internals(simulation); + simulationApi.validateDeposit = vi.fn().mockResolvedValue({ + channelId, + deposit: 1_000n, + expectedDeposit: 11_000n, + isTopUp: true, + payload: deposit, + terms, + }); + simulationApi.readChannel = vi.fn().mockResolvedValue(undefined); + const simulationDelete = vi.fn(); + simulationApi.settlementCache = { + delete: simulationDelete, + isDuplicate: vi.fn().mockReturnValue(false), + }; + await expect( + simulationApi.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + deposit, + requirements(), + ), + ).rejects.toThrow(BatchError.SETTLEMENT_SIMULATION); + expect(simulationDelete).toHaveBeenCalledWith(`batch:topup:${NETWORK}:open-a`); + + const classifiedSimulation = new BatchSvmScheme( + signer({ + simulateTransaction: vi + .fn() + .mockRejectedValue( + new Error(`${BatchError.SETTLEMENT_SIMULATION}: missing treasury ATA`), + ), + }) as never, + { receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore() }, + ); + const classifiedApi = internals(classifiedSimulation); + classifiedApi.validateDeposit = simulationApi.validateDeposit; + classifiedApi.readChannel = vi.fn().mockResolvedValue(undefined); + classifiedApi.settlementCache = { + delete: vi.fn(), + isDuplicate: vi.fn().mockReturnValue(false), + }; + await expect( + classifiedApi.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + deposit, + requirements(), + ), + ).rejects.toThrow(`${BatchError.SETTLEMENT_SIMULATION}: missing treasury ATA`); + + const indexing = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const indexingApi = internals(indexing); + indexingApi.validateDeposit = simulationApi.validateDeposit; + indexingApi.readChannel = vi.fn().mockResolvedValue(undefined); + indexingApi.trackChannel = vi.fn().mockRejectedValue(new Error("storage unavailable")); + const indexingDelete = vi.fn(); + indexingApi.settlementCache = { + delete: indexingDelete, + isDuplicate: vi.fn().mockReturnValue(false), + }; + await expect( + indexingApi.settleDeposit( + { accepted: requirements(), payload: deposit, x402Version: 2 }, + deposit, + requirements(), + ), + ).rejects.toThrow("storage unavailable"); + expect(indexingDelete).toHaveBeenCalledWith(`batch:topup:${NETWORK}:open-a`); + }); + + it("prepares and confirms a voucher claim batch", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const privateApi = internals(scheme); + privateApi.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + privateApi.deriveChannelId = vi.fn().mockResolvedValue(channelId); + privateApi.fetchChannel = vi.fn().mockResolvedValue(channel()); + privateApi.fetchChannelsUntil = vi + .fn() + .mockResolvedValue([channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })]); + privateApi.trackChannel = vi.fn().mockResolvedValue(undefined); + privateApi.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + + const signed = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }); + const payload: BatchClaimPayload = { + type: "claim", + claims: [ + { + channelConfig, + channelId, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "1000", + signature: signed.signature, + }, + }, + ], + }; + await expect( + scheme.settleClaims( + { accepted: requirements(), payload, x402Version: 2 } as never, + payload, + requirements(), + ), + ).resolves.toMatchObject({ + success: true, + transaction: SIGNATURE, + extra: { accepts: [{ channelId, totalClaimed: "1000" }] }, + }); + expect(privateApi.trackChannel).toHaveBeenCalledOnce(); + expect(privateApi.submitRedemption).toHaveBeenCalledOnce(); + }); + + it("rejects invalid claim batches at each lifecycle boundary", async () => { + const signed = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: 1_000n, + }); + const claim = (overrides: Record = {}): BatchClaimPayload => ({ + type: "claim", + claims: [ + { + channelConfig, + channelId, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "1000", + ...overrides, + signature: signed.signature, + }, + }, + ], + }); + const configured = () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.fetchChannel = vi.fn().mockResolvedValue(channel()); + api.fetchChannelsUntil = vi + .fn() + .mockResolvedValue([channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })]); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + api.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + return { api, scheme }; + }; + const settle = (scheme: BatchSvmScheme, payload: BatchClaimPayload) => + scheme.settleClaims( + { accepted: requirements(), payload, x402Version: 2 } as never, + payload, + requirements(), + ); + + const mismatch = configured(); + mismatch.api.deriveChannelId = vi.fn().mockResolvedValue(payer.address); + await expect(settle(mismatch.scheme, claim())).rejects.toThrow(BatchError.CHANNEL_ID_MISMATCH); + + for (const value of [0n, 10_001n]) { + const bounds = configured(); + const boundedClaim = claim({ maxClaimableAmount: value.toString() }); + boundedClaim.claims[0]!.voucher.signature = ( + await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: value, + }) + ).signature; + await expect(settle(bounds.scheme, boundedClaim)).rejects.toThrow( + BatchError.CUMULATIVE_AMOUNT_MISMATCH, + ); + } + const signature = configured(); + await expect(settle(signature.scheme, claim({ maxClaimableAmount: "1001" }))).rejects.toThrow( + BatchError.VOUCHER_SIGNATURE, + ); + + const rejected = configured(); + rejected.api.submitRedemption = vi.fn().mockResolvedValue({ + ok: false, + response: { errorReason: "settlement_pending", success: false }, + }); + await expect(settle(rejected.scheme, claim())).resolves.toMatchObject({ + errorReason: "settlement_pending", + success: false, + }); + + const unconfirmed = configured(); + unconfirmed.api.fetchChannelsUntil = vi.fn().mockResolvedValue(undefined); + await expect(settle(unconfirmed.scheme, claim())).resolves.toMatchObject({ + errorReason: "settlement_pending", + success: false, + transaction: SIGNATURE, + }); + + const split = configured(); + split.api.assertClaimChannel = vi.fn(); + split.api.resolveTerms = vi + .fn() + .mockResolvedValueOnce({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }) + .mockResolvedValueOnce({ + feePayer: payer.address, + feePayerSigner: payer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const two = claim(); + two.claims.push({ ...two.claims[0] }); + await expect(settle(split.scheme, two)).rejects.toThrow(BatchError.FEE_PAYER_MISMATCH); + }); + + it("prepares and confirms a distribution batch", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const privateApi = internals(scheme); + privateApi.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + privateApi.deriveChannelId = vi.fn().mockResolvedValue(channelId); + privateApi.fetchChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 200n, settled: 1_000n } })); + privateApi.fetchChannelsUntil = vi + .fn() + .mockResolvedValue([channel({ settlement: { payoutWatermark: 1_000n, settled: 1_000n } })]); + privateApi.distributeInstruction = vi.fn().mockResolvedValue({ + accounts: [], + data: new Uint8Array([7]), + programAddress: address(USDC_MAINNET_ADDRESS), + }); + privateApi.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + privateApi.trackChannel = vi.fn().mockResolvedValue(undefined); + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelConfig, channelId }], + }; + await expect( + scheme.settleDistributions( + { accepted: requirements(), payload, x402Version: 2 } as never, + payload, + requirements(), + ), + ).resolves.toMatchObject({ amount: "800", success: true, transaction: SIGNATURE }); + // A confirmed distribution is facilitator-visible activity (spec Phase 4): + // it resets the idle clock for the channel it paid. + expect(privateApi.trackChannel).toHaveBeenCalledOnce(); + expect(privateApi.trackChannel).toHaveBeenCalledWith({ + channelId, + expiresAt: 0, + network: NETWORK, + payTo: RECEIVER, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + // Replaying the same confirmed result is not new activity. + privateApi.trackChannel.mockClear(); + privateApi.readChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 200n, settled: 1_000n } })); + await expect( + scheme.settleDistributions( + { accepted: requirements(), payload, x402Version: 2 } as never, + payload, + requirements(), + ), + ).resolves.toMatchObject({ amount: "800", success: true, transaction: SIGNATURE }); + expect(privateApi.trackChannel).not.toHaveBeenCalled(); + }); + + it("rejects invalid distribution batches at each lifecycle boundary", async () => { + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelConfig, channelId }], + }; + const configured = () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.fetchChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })); + api.fetchChannelsUntil = vi + .fn() + .mockResolvedValue([channel({ settlement: { payoutWatermark: 1_000n, settled: 1_000n } })]); + api.distributeInstruction = vi.fn().mockResolvedValue({ + accounts: [], + data: new Uint8Array([7]), + programAddress: address(RECEIVER), + }); + api.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + return { api, scheme }; + }; + const settle = (scheme: BatchSvmScheme, value = payload) => + scheme.settleDistributions( + { accepted: requirements(), payload: value, x402Version: 2 } as never, + value, + requirements(), + ); + const mismatch = configured(); + mismatch.api.deriveChannelId = vi.fn().mockResolvedValue(payer.address); + await expect(settle(mismatch.scheme)).rejects.toThrow(BatchError.CHANNEL_ID_MISMATCH); + + const rejected = configured(); + rejected.api.submitRedemption = vi.fn().mockResolvedValue({ + ok: false, + response: { errorReason: "settlement_pending", success: false }, + }); + await expect(settle(rejected.scheme)).resolves.toMatchObject({ success: false }); + + const unconfirmed = configured(); + unconfirmed.api.fetchChannelsUntil = vi.fn().mockResolvedValue(undefined); + await expect(settle(unconfirmed.scheme)).resolves.toMatchObject({ + amount: "800", + success: true, + transaction: SIGNATURE, + }); + + const split = configured(); + split.api.assertClaimChannel = vi.fn(); + split.api.resolveTerms = vi + .fn() + .mockResolvedValueOnce({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }) + .mockResolvedValueOnce({ + feePayer: payer.address, + feePayerSigner: payer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + await expect( + settle(split.scheme, { ...payload, channels: [...payload.channels, ...payload.channels] }), + ).rejects.toThrow(BatchError.PAYLOAD_TYPE); + }); + + it("rejects empty and oversized redemption batches", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const emptyClaims: BatchClaimPayload = { claims: [], type: "claim" }; + await expect( + scheme.settleClaims( + { accepted: requirements(), payload: emptyClaims, x402Version: 2 } as never, + emptyClaims, + requirements(), + ), + ).rejects.toThrow(BatchError.FEE_PAYER_MISMATCH); + + const oversized: BatchSettlePayload = { + type: "settle", + channels: Array.from({ length: MAX_CHANNELS_PER_SETTLE_TX + 1 }, () => ({ + channelConfig, + channelId, + payoutWatermark: "0", + settled: "1", + })), + }; + await expect( + scheme.settleDistributions( + { accepted: requirements(), payload: oversized, x402Version: 2 } as never, + oversized, + requirements(), + ), + ).rejects.toThrow(/invalid channel batch/); + }); + + it("persists, reconciles, and forgets durable broadcast signatures", async () => { + const facilitatorSigner = signer(); + const scheme = new BatchSvmScheme(facilitatorSigner as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const privateApi = internals(scheme); + const first = await privateApi.broadcastDurably( + "key", + NETWORK, + payer.address, + async onBroadcast => { + await onBroadcast(SIGNATURE, "signed-wire"); + return SIGNATURE; + }, + ); + expect(first).toMatchObject({ ok: true, signature: SIGNATURE }); + + const pendingStore = { + delete: vi.fn().mockResolvedValue(undefined), + get: vi + .fn() + .mockImplementation(async (key: string) => + key.endsWith(":completed") ? undefined : SIGNATURE, + ), + set: vi.fn().mockResolvedValue(undefined), + }; + const recovering = new BatchSvmScheme(facilitatorSigner as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: pendingStore, + }); + await expect( + internals(recovering).broadcastDurably("key", NETWORK, payer.address, vi.fn()), + ).resolves.toMatchObject({ ok: true, signature: SIGNATURE }); + expect(facilitatorSigner.confirmTransaction).toHaveBeenCalledWith(SIGNATURE, NETWORK, { + searchTransactionHistory: true, + }); + expect(pendingStore.delete).not.toHaveBeenCalled(); + }); + + it("distinguishes pre-broadcast, pending, and onchain-terminal failures", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme); + await expect( + api.broadcastDurably("ordinary", NETWORK, payer.address, async () => { + throw new Error("send failed"); + }), + ).rejects.toThrow("send failed"); + await expect( + api.broadcastDurably("pending", NETWORK, payer.address, async () => { + throw new ChannelBroadcastConfirmationError(SIGNATURE, new Error("timeout")); + }), + ).resolves.toMatchObject({ + ok: false, + response: { errorReason: "settlement_pending", transaction: SIGNATURE }, + }); + + const terminalStore = { + delete: vi.fn().mockResolvedValue(undefined), + get: vi + .fn() + .mockImplementation(async (key: string) => + key.endsWith(":completed") ? undefined : SIGNATURE, + ), + set: vi.fn().mockResolvedValue(undefined), + }; + const terminal = new BatchSvmScheme( + signer({ + confirmTransaction: vi.fn().mockRejectedValue(new TransactionOnchainFailureError("failed")), + }) as never, + { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: terminalStore, + }, + ); + await expect( + internals(terminal).broadcastDurably("terminal", NETWORK, payer.address, vi.fn()), + ).resolves.toMatchObject({ + ok: false, + response: { errorReason: "transaction_failed", transaction: SIGNATURE }, + }); + expect(terminalStore.delete).toHaveBeenCalled(); + + const retryStore = { + delete: vi.fn().mockRejectedValue(new Error("delete failed")), + get: vi + .fn() + .mockImplementation(async (key: string) => + key.endsWith(":completed") ? undefined : SIGNATURE, + ), + set: vi.fn().mockResolvedValue(undefined), + }; + const retry = new BatchSvmScheme( + signer({ confirmTransaction: vi.fn().mockRejectedValue(new Error("timeout")) }) as never, + { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: retryStore, + }, + ); + await expect( + internals(retry).broadcastDurably("retry", NETWORK, payer.address, vi.fn()), + ).resolves.toMatchObject({ ok: false, response: { errorReason: "settlement_pending" } }); + }); + + it("rejects invalid expiry, fee payer, deposit, and distribution arithmetic", () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = internals(scheme) as FacilitatorInternals & { + resolveFeePayer(value: string): unknown; + assertDepositChannel(value: Channel, validated: unknown, req: PaymentRequirements): void; + }; + expect(() => api.assertExpiry(1)).toThrow(BatchError.VOUCHER_EXPIRY); + expect(() => api.resolveFeePayer(payer.address)).toThrow(BatchError.FEE_PAYER_MISMATCH); + expect(() => + api.assertDepositChannel( + channel({ deposit: 1n }), + { + expectedDeposit: 2n, + payload: { channelConfig }, + terms: { + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + }, + requirements(), + ), + ).toThrow(/confirmed deposit mismatch/); + expect(() => calculateDistributionAmount([{ payoutWatermark: 2n, settled: 1n }])).toThrow( + /payout watermark exceeds/, + ); + }); +}); + +async function payoutEvidence(before = "200", after = "1000") { + const [recipient] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(RECEIVER), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const [escrow] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(channelId), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const token = (accountIndex: number, owner: string, amount: string) => ({ + accountIndex, + mint: MINT, + owner, + uiTokenAmount: { amount }, + }); + return { + slot: 100n, + transaction: { message: { accountKeys: [recipient, escrow] } }, + meta: { + err: null, + preTokenBalances: [token(0, RECEIVER, before), token(1, channelId, "9800")], + postTokenBalances: [token(0, RECEIVER, after), token(1, channelId, "9000")], + }, + }; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.operation-store.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.operation-store.test.ts new file mode 100644 index 0000000000..57d0b91271 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.operation-store.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { MemoryBatchOperationStore } from "../../src/batch-settlement/server/operationStore"; + +describe("MemoryBatchOperationStore", () => { + it("atomically reserves one operation per request id", async () => { + const store = new MemoryBatchOperationStore(); + const [first, second] = await Promise.all([ + store.reserve("channel", "request", 1_000n), + store.reserve("channel", "request", 1_000n), + ]); + expect([first.created, second.created].sort()).toEqual([false, true]); + await expect(store.reserve("channel", "request", 999n)).rejects.toThrow("ceiling changed"); + }); + + it("permanently rejects failed and completed request ids", async () => { + const store = new MemoryBatchOperationStore(); + await store.reserve("channel", "request", 1_000n); + await store.release("channel", "request"); + expect((await store.reserve("channel", "request", 1_000n)).created).toBe(false); + + const completedStore = new MemoryBatchOperationStore(); + await completedStore.reserve("channel", "request", 1_000n); + await completedStore.complete({ + actual: 500n, + ceiling: 1_000n, + channelId: "channel", + cumulative: 500n, + requestId: "request", + status: "completed", + }); + const replay = await completedStore.reserve("channel", "request", 1_000n); + expect(replay).toMatchObject({ created: false, operation: { status: "completed" } }); + await completedStore.release("channel", "request"); + expect((await completedStore.get("channel", "request"))?.status).toBe("completed"); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.pendingSettlement.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.pendingSettlement.test.ts new file mode 100644 index 0000000000..1e68b7e914 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.pendingSettlement.test.ts @@ -0,0 +1,230 @@ +import { + generateKeyPairSigner, + address, + getSignatureFromTransaction, + getTransactionDecoder, + getBase64Codec, +} from "@solana/kit"; +import { InMemoryPendingSettlementStore } from "@x402/core/facilitator"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { BatchSvmScheme as BatchFacilitatorScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { broadcastOpen } from "../../src/payment-channels/facilitator"; +import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; +import { SOLANA_DEVNET_CAIP2, MEMO_PROGRAM_ADDRESS } from "../../src/constants"; +import { toFacilitatorSvmSigner } from "../../src/signer"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const KEY = "batch:claim:test"; + +/** Reaches the private broadcast path the settle handlers share. */ +type Durable = { + broadcastDurably( + key: string, + network: string, + payer: string, + broadcast: (onBroadcast: (signature: string) => Promise) => Promise, + ): Promise<{ ok: true; replayed: boolean; signature: string } | { ok: false; response: unknown }>; + completeBroadcast(key: string, signature: string): Promise; +}; + +describe("batch-settlement pending settlement", () => { + let payer: string; + + beforeAll(async () => { + payer = (await generateKeyPairSigner()).address; + }); + + /** + * A facilitator whose confirmations always succeed, over `store`. + * + * @param store - The pending-settlement store to use + * @returns The facilitator, reached through its durable broadcast path + */ + async function facilitator(store: InMemoryPendingSettlementStore): Promise { + const signer = toFacilitatorSvmSigner(await generateKeyPairSigner(), { + defaultRpcUrl: "http://127.0.0.1:9", + }); + const scheme = new BatchFacilitatorScheme( + { ...signer, confirmTransaction: async () => undefined }, + { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }, + ); + return scheme as unknown as Durable; + } + + it.each(["redemption", "open"])( + "does not repeat the successful confirmation RPC for %s", + async kind => { + const wallet = await generateKeyPairSigner(); + const confirm = vi.fn().mockResolvedValue({ slot: 432n }); + let wire = ""; + const transport = { + ...toFacilitatorSvmSigner(wallet), + getLatestBlockhash: vi + .fn() + .mockResolvedValue({ blockhash: USDC_DEVNET_ADDRESS, lastValidBlockHeight: 1n }), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + sendTransaction: vi.fn(async (bytes: string) => { + wire = bytes; + return getSignatureFromTransaction( + getTransactionDecoder().decode(getBase64Codec().encode(bytes)), + ); + }), + confirmTransaction: confirm, + getAccountInfo: vi.fn().mockResolvedValue(null), + }; + const scheme = new BatchFacilitatorScheme(transport, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const internals = scheme as any; + const instructions = [ + { + programAddress: address(MEMO_PROGRAM_ADDRESS), + accounts: [], + data: new TextEncoder().encode("RPC budget"), + }, + ]; + const result = await internals.submitRedemption( + wallet.address, + NETWORK, + instructions, + "redemption", + payer, + ); + expect(result.ok).toBe(true); + if (kind === "open") { + confirm.mockClear(); + transport.signTransaction = vi.fn().mockImplementation(async () => wire); + await internals.broadcastDurably("open", NETWORK, payer, (onPrepared: any) => + broadcastOpen( + internals.submissionSigner(), + wallet.address, + NETWORK, + wire, + undefined, + onPrepared, + ), + ); + } + expect(confirm).toHaveBeenCalledTimes(1); + expect(confirm.mock.calls[0]).toHaveLength(2); // no history search on fresh submission + await internals.readChannel(NETWORK, wallet.address); + expect(transport.getAccountInfo).toHaveBeenCalledWith( + wallet.address, + NETWORK, + expect.objectContaining({ minContextSlot: 432n }), + ); + // Reconciliation is allowed to search older transaction history, without building new bytes. + confirm.mockClear(); + await internals.reconcileBroadcast(kind, result.signature, NETWORK, payer); + expect(confirm).toHaveBeenCalledWith(result.signature, NETWORK, { + searchTransactionHistory: true, + }); + }, + ); + + it("reconciles a recorded broadcast instead of repeating it", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set(KEY, "recorded-signature"); + const durable = await facilitator(store); + let broadcasts = 0; + + const result = await durable.broadcastDurably(KEY, NETWORK, payer, async () => { + broadcasts += 1; + return "fresh-signature"; + }); + + expect(result).toMatchObject({ ok: true, signature: "recorded-signature" }); + expect(broadcasts, "the recorded transaction is confirmed, never resent").toBe(0); + // Confirmation alone is not the operation outcome: its identity remains + // recoverable until the claim/distribution/close postcondition is visible. + expect(await store.get(KEY)).toBe("recorded-signature"); + }); + + it("keeps the record until the reconcile knows the outcome", async () => { + // The property a concurrent retry depends on. Dropping the record first + // would leave a racing retry reading nothing and broadcasting the work a + // second time — and the in-memory duplicate cache that would otherwise + // catch it is empty after a restart, which is exactly when a pending + // record is being reconciled. + const store = new InMemoryPendingSettlementStore(); + await store.set(KEY, "recorded-signature"); + const signer = toFacilitatorSvmSigner(await generateKeyPairSigner(), { + defaultRpcUrl: "http://127.0.0.1:9", + }); + let recordDuringConfirm: string | undefined; + const scheme = new BatchFacilitatorScheme( + { + ...signer, + confirmTransaction: async () => { + // A racing retry reads the store at exactly this point. + recordDuringConfirm = await store.get(KEY); + }, + }, + { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }, + ); + let broadcasts = 0; + + const result = await (scheme as unknown as Durable).broadcastDurably( + KEY, + NETWORK, + payer, + async () => { + broadcasts += 1; + return "fresh-signature"; + }, + ); + + expect(recordDuringConfirm, "a racing retry must still find the record").toBe( + "recorded-signature", + ); + expect(result).toMatchObject({ ok: true, signature: "recorded-signature" }); + expect(broadcasts).toBe(0); + expect(await store.get(KEY)).toBe("recorded-signature"); + await (scheme as unknown as Durable).completeBroadcast(KEY, "recorded-signature"); + expect(await store.get(KEY)).toBeUndefined(); + expect(await store.get(`${KEY}:completed`)).toBe("recorded-signature"); + }); + + it("has both concurrent retries reconcile rather than rebroadcast", async () => { + const store = new InMemoryPendingSettlementStore(); + await store.set(KEY, "recorded-signature"); + const durable = await facilitator(store); + let broadcasts = 0; + const attempt = () => + durable.broadcastDurably(KEY, NETWORK, payer, async () => { + broadcasts += 1; + return "fresh-signature"; + }); + + const [first, second] = await Promise.all([attempt(), attempt()]); + + expect(broadcasts).toBe(0); + expect(first).toMatchObject({ ok: true, signature: "recorded-signature" }); + expect(second).toMatchObject({ ok: true, signature: "recorded-signature" }); + }); + + it("records a broadcast before its confirmation is awaited", async () => { + // A process that dies waiting must leave the signature behind, or the + // retry has nothing to reconcile against and broadcasts afresh. + const store = new InMemoryPendingSettlementStore(); + const durable = await facilitator(store); + let recordedMidFlight: string | undefined; + + await durable.broadcastDurably(KEY, NETWORK, payer, async onBroadcast => { + await onBroadcast("in-flight-signature"); + recordedMidFlight = await store.get(KEY); + return "in-flight-signature"; + }); + + expect(recordedMidFlight).toBe("in-flight-signature"); + expect(await store.get(KEY)).toBe("in-flight-signature"); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.readChannel.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.readChannel.test.ts new file mode 100644 index 0000000000..ceb1f83064 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.readChannel.test.ts @@ -0,0 +1,73 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { describe, expect, it, vi } from "vitest"; + +import { BatchSvmScheme as BatchFacilitatorScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { toFacilitatorSvmSigner } from "../../src/signer"; + +const NETWORK = SOLANA_DEVNET_CAIP2; + +/** Reaches the private channel-read helpers. */ +type Internals = { + readChannel(network: string, channelId: string): Promise; + rememberSlot(network: string, slot: bigint): void; + waitForChannelRead(attempt: number): Promise; +}; + +/** + * A facilitator whose account reads are scripted by `getAccountInfo`. + * + * @param getAccountInfo - Scripted transport read + * @returns The facilitator's private read helpers + */ +async function facilitator(getAccountInfo: ReturnType): Promise { + const wallet = await generateKeyPairSigner(); + const transport = { ...toFacilitatorSvmSigner(wallet), getAccountInfo }; + const scheme = new BatchFacilitatorScheme(transport, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }) as unknown as Internals; + vi.spyOn(scheme, "waitForChannelRead").mockResolvedValue(undefined); + return scheme; +} + +describe("batch-settlement channel reads under a confirmation slot floor", () => { + it("retries a read the backend rejects for the remembered slot, then returns the account", async () => { + const getAccountInfo = vi + .fn() + .mockRejectedValueOnce(new Error("Minimum context slot has not been reached")) + .mockRejectedValueOnce(new Error("Minimum context slot has not been reached")) + .mockResolvedValueOnce(null); + const internals = await facilitator(getAccountInfo); + internals.rememberSlot(NETWORK, 321n); + + await expect(internals.readChannel(NETWORK, "channel")).resolves.toBeUndefined(); + + expect(getAccountInfo).toHaveBeenCalledTimes(3); + for (const call of getAccountInfo.mock.calls) { + expect(call[2]).toMatchObject({ commitment: "confirmed", minContextSlot: 321n }); + } + }); + + it("gives up after the read budget when the backend never catches up", async () => { + const getAccountInfo = vi + .fn() + .mockRejectedValue(new Error("Minimum context slot has not been reached")); + const internals = await facilitator(getAccountInfo); + internals.rememberSlot(NETWORK, 321n); + + await expect(internals.readChannel(NETWORK, "channel")).rejects.toThrow( + "Minimum context slot has not been reached", + ); + expect(getAccountInfo).toHaveBeenCalledTimes(5); + }); + + it("surfaces a read error at once when no slot floor is in effect", async () => { + const getAccountInfo = vi.fn().mockRejectedValue(new Error("rpc unavailable")); + const internals = await facilitator(getAccountInfo); + + await expect(internals.readChannel(NETWORK, "channel")).rejects.toThrow("rpc unavailable"); + expect(getAccountInfo).toHaveBeenCalledTimes(1); + expect(getAccountInfo.mock.calls[0][2]).toMatchObject({ minContextSlot: undefined }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.receiverAuthorizer.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.receiverAuthorizer.test.ts new file mode 100644 index 0000000000..6c9014a095 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.receiverAuthorizer.test.ts @@ -0,0 +1,363 @@ +import { address, generateKeyPairSigner, getBase64Codec, type Signature } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { buildDepositPayload } from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { + BatchReceiverAuthorizerConflictError, + InMemoryBatchReceiverAuthorizerStore, +} from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import type { BatchReceiverBindingHistoryReader } from "../../src/batch-settlement/facilitator/types"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { + encodeReceiverBindingMemo, + parseReceiverBindingMemo, + RECEIVER_BINDING_MEMO_PREFIX, +} from "../../src/batch-settlement/receiverBinding"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import { getChannelDistributionHash } from "../../src/payment-channels/facilitator"; +import { ChannelStatus } from "../../src/payment-channels/onchain"; +import { + buildOpenPaymentChannelTransaction, + verifyOpenTransaction, + type BuildOpenArgs, +} from "../../src/payment-channels/open"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const SIGNATURE = USDC_DEVNET_ADDRESS as Signature; +const OPEN_SLOT = 123n; + +let payer: Awaited>; +let feePayer: Awaited>; +let server: Awaited>; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + server = await generateKeyPairSigner(); +}); + +function requirements(receiverAuthorizer: string): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function openArgs(overrides: Partial = {}): BuildOpenArgs { + return { + authorizedSigner: payer.address, + bindingMemo: encodeReceiverBindingMemo(server.address), + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + deposit: 10_000n, + feePayer: feePayer.address, + gracePeriod: 900, + mint: MINT, + openSlot: OPEN_SLOT, + payee: feePayer.address, + payer, + recipients: [{ bps: 10_000, recipient: RECEIVER }], + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + ...overrides, + }; +} + +function verifyOpen(transaction: string, expectedBindingMemo?: string) { + return verifyOpenTransaction(transaction, { + authorizedSigner: payer.address, + expectedBindingMemo, + feePayer: feePayer.address, + from: payer.address, + maxCap: 10_000n, + mint: MINT, + openSlot: OPEN_SLOT, + payee: feePayer.address, + recipients: [{ bps: 10_000, recipient: RECEIVER }], + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); +} + +function channel(overrides: Partial = {}): Channel { + return { + authorizedSigner: address(payer.address), + bump: 1, + closureStartedAt: 0n, + deposit: 10_000n, + discriminator: 1, + distributionHash: getChannelDistributionHash([{ bps: 10_000, recipient: RECEIVER }]), + gracePeriod: 900, + mint: address(MINT), + openSlot: OPEN_SLOT, + payee: address(feePayer.address), + payer: address(payer.address), + payerWithdrawnAt: 0n, + rentPayer: address(feePayer.address), + salt: 0n, + settlement: { payoutWatermark: 0n, settled: 0n }, + status: ChannelStatus.Open, + version: 1, + ...overrides, + }; +} + +function signer() { + return { + confirmTransaction: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_PROGRAM_ADDRESS }), + getAddresses: vi.fn(() => [feePayer.address]), + getSigner: vi.fn(() => feePayer), + sendTransaction: vi.fn().mockResolvedValue(SIGNATURE), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + }; +} + +describe("batch-settlement receiver-authorizer binding", () => { + it("keeps the first binding for a channel and refuses a different key", async () => { + const store = new InMemoryBatchReceiverAuthorizerStore(); + const binding = { channelId: RECEIVER, network: NETWORK, receiverAuthorizer: server.address }; + await store.bind(binding); + await expect(store.bind(binding)).resolves.toBeUndefined(); + await expect(store.bind({ ...binding, receiverAuthorizer: payer.address })).rejects.toThrow( + BatchReceiverAuthorizerConflictError, + ); + expect(await store.get(NETWORK, RECEIVER)).toEqual(binding); + // The same PDA on another network is a separate channel. + expect(await store.get("solana:other", RECEIVER)).toBeUndefined(); + await store.delete(NETWORK, RECEIVER); + expect(await store.get(NETWORK, RECEIVER)).toBeUndefined(); + }); + + it("round-trips the binding memo and rejects anything else", () => { + const memo = encodeReceiverBindingMemo(server.address); + expect(memo).toBe(`${RECEIVER_BINDING_MEMO_PREFIX}${server.address}`); + expect(parseReceiverBindingMemo(memo)).toBe(server.address); + expect(parseReceiverBindingMemo(server.address)).toBeUndefined(); + expect(parseReceiverBindingMemo(`${RECEIVER_BINDING_MEMO_PREFIX}not-a-key`)).toBeUndefined(); + expect(parseReceiverBindingMemo(RECEIVER_BINDING_MEMO_PREFIX)).toBeUndefined(); + }); + + it("requires exactly one matching binding memo in the open", async () => { + const expected = encodeReceiverBindingMemo(server.address); + const bound = await buildOpenPaymentChannelTransaction(openArgs({ memo: "invoice-42" })); + await expect(verifyOpen(bound.transaction, expected)).resolves.toMatchObject({ + channelId: bound.channelId, + }); + // Without an expectation the verifier keeps its previous behavior. + const unbound = await buildOpenPaymentChannelTransaction(openArgs({ bindingMemo: undefined })); + await expect(verifyOpen(unbound.transaction)).resolves.toBeDefined(); + + await expect(verifyOpen(unbound.transaction, expected)).rejects.toThrow(/found 0/); + const duplicate = await buildOpenPaymentChannelTransaction(openArgs({ memo: expected })); + await expect(verifyOpen(duplicate.transaction, expected)).rejects.toThrow(/found 2/); + const other = await buildOpenPaymentChannelTransaction( + openArgs({ bindingMemo: encodeReceiverBindingMemo(payer.address) }), + ); + await expect(verifyOpen(other.transaction, expected)).rejects.toThrow(/found 0/); + const both = await buildOpenPaymentChannelTransaction( + openArgs({ memo: encodeReceiverBindingMemo(payer.address) }), + ); + await expect(verifyOpen(both.transaction, expected)).rejects.toThrow(/found 2/); + }); + + it("keeps a bound open within the packet limit and refuses an oversized one", async () => { + const open = await buildOpenPaymentChannelTransaction(openArgs({ memo: "invoice-42" })); + expect(getBase64Codec().encode(open.transaction).byteLength).toBeLessThanOrEqual(1232); + await expect( + buildOpenPaymentChannelTransaction( + openArgs({ bindingMemo: "x".repeat(1_000), memo: "invoice-42" }), + ), + ).rejects.toThrow(/1232-byte packet limit/); + }); + + it("refuses a channel the payer bound to its own key when the server's key is advertised", async () => { + const store = new InMemoryBatchReceiverAuthorizerStore(); + const scheme = new BatchSvmScheme(signer() as never, { receiverAuthorizerStore: store }); + const api = scheme as unknown as Record>; + // The payer opens directly against the facilitator, advertising its own key. + const attack = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: OPEN_SLOT, + payer, + receiver: RECEIVER, + receiverAuthorizer: payer.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const channelId = attack.channelId; + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.fetchChannel = vi.fn().mockResolvedValue(channel()); + api.broadcastDurably = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + api.completeOrPending = vi.fn().mockResolvedValue(undefined); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + await expect( + scheme.settle( + { accepted: requirements(payer.address), payload: attack.payload, x402Version: 2 }, + requirements(payer.address), + ), + ).resolves.toMatchObject({ success: true, transaction: SIGNATURE }); + expect((await store.get(NETWORK, channelId))?.receiverAuthorizer).toBe(payer.address); + + // Voucher and top-up do not read the binding. Cooperative refund does. + const serverRequirements = requirements(server.address); + const channelConfig = { ...attack.payload.channelConfig, receiverAuthorizer: server.address }; + const reads = vi.spyOn(store, "get"); + await expect( + scheme.verify( + { + accepted: serverRequirements, + payload: { channelConfig, type: "voucher", voucher: attack.payload.voucher }, + x402Version: 2, + }, + serverRequirements, + ), + ).resolves.toMatchObject({ isValid: true }); + api.readChannel = vi.fn().mockResolvedValue(channel()); + await scheme.verify( + { + accepted: serverRequirements, + payload: { ...attack.payload, channelConfig }, + x402Version: 2, + }, + serverRequirements, + ); + expect(reads).not.toHaveBeenCalled(); + api.readChannel = vi.fn().mockResolvedValue(undefined); + await expect( + scheme.verify( + { + accepted: serverRequirements, + payload: { channelConfig, type: "refund", voucher: attack.payload.voucher }, + x402Version: 2, + }, + serverRequirements, + ), + ).resolves.toMatchObject({ + invalidReason: BatchError.RECEIVER_AUTHORIZER_MISMATCH, + isValid: false, + }); + }); + + it("broadcasts an open only after a store-only bind reads back", async () => { + const attack = await openedDeposit(); + const down = new InMemoryBatchReceiverAuthorizerStore(); + vi.spyOn(down, "bind").mockRejectedValue(new Error("store down")); + const failed = await facilitatorFor(down); + await expect(failed.scheme.settle(attack.payment, attack.requirements)).resolves.toMatchObject({ + errorReason: "transaction_failed", + success: false, + }); + expect(failed.broadcast).not.toHaveBeenCalled(); + + const unread = new InMemoryBatchReceiverAuthorizerStore(); + vi.spyOn(unread, "get").mockResolvedValue(undefined); + const missing = await facilitatorFor(unread); + await expect(missing.scheme.settle(attack.payment, attack.requirements)).resolves.toMatchObject( + { + errorReason: BatchError.RECEIVER_BINDING_UNAVAILABLE, + success: false, + }, + ); + expect(missing.broadcast).not.toHaveBeenCalled(); + + const stored = new InMemoryBatchReceiverAuthorizerStore(); + const opened = await facilitatorFor(stored); + await expect(opened.scheme.settle(attack.payment, attack.requirements)).resolves.toMatchObject({ + success: true, + transaction: SIGNATURE, + }); + expect(opened.broadcast).toHaveBeenCalledOnce(); + expect((await stored.get(NETWORK, attack.channelId))?.receiverAuthorizer).toBe(payer.address); + }); + + it("still broadcasts when a store write fails and history is configured", async () => { + const attack = await openedDeposit(); + const store = new InMemoryBatchReceiverAuthorizerStore(); + vi.spyOn(store, "bind").mockRejectedValue(new Error("store down")); + const { broadcast, scheme } = await facilitatorFor(store, { + getSignaturesForAddress: vi.fn(), + getTransaction: vi.fn(), + }); + await expect(scheme.settle(attack.payment, attack.requirements)).resolves.toMatchObject({ + success: true, + transaction: SIGNATURE, + }); + expect(broadcast).toHaveBeenCalledOnce(); + expect(await store.get(NETWORK, attack.channelId)).toBeUndefined(); + }); + + it("skips the bind when only a history reader is configured", async () => { + const attack = await openedDeposit(); + const { broadcast, scheme } = await facilitatorFor(undefined, { + getSignaturesForAddress: vi.fn(), + getTransaction: vi.fn(), + }); + await expect(scheme.settle(attack.payment, attack.requirements)).resolves.toMatchObject({ + success: true, + transaction: SIGNATURE, + }); + expect(broadcast).toHaveBeenCalledOnce(); + }); +}); + +async function openedDeposit() { + const built = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: OPEN_SLOT, + payer, + receiver: RECEIVER, + receiverAuthorizer: payer.address, + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + return { + channelId: built.channelId, + payment: { accepted: requirements(payer.address), payload: built.payload, x402Version: 2 }, + requirements: requirements(payer.address), + }; +} + +async function facilitatorFor( + store: InMemoryBatchReceiverAuthorizerStore | undefined, + historyReader?: BatchReceiverBindingHistoryReader, +) { + const scheme = new BatchSvmScheme(signer() as never, { + ...(store ? { receiverAuthorizerStore: store } : {}), + ...(historyReader ? { receiverBindingHistoryReader: historyReader } : {}), + }); + const api = scheme as unknown as Record>; + const broadcast = vi.fn().mockResolvedValue({ ok: true, signature: SIGNATURE }); + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.fetchChannel = vi.fn().mockResolvedValue(channel()); + api.broadcastDurably = broadcast; + api.completeOrPending = vi.fn().mockResolvedValue(undefined); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + return { broadcast, scheme }; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.receiverBindingHistoryReader.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.receiverBindingHistoryReader.test.ts new file mode 100644 index 0000000000..8426123389 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.receiverBindingHistoryReader.test.ts @@ -0,0 +1,288 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { buildOpenPaymentChannelTransaction } from "../../src/payment-channels/open"; +import { + assertBindingSource, + assertDelegatedReceiverAuth, + isDelegatedAuthorizer, + readReceiverAuthorizer, + resolveDelegatedIdentity, +} from "../../src/batch-settlement/facilitator/bindingSource"; +import { InMemoryBatchDelegatedAuthStore } from "../../src/batch-settlement/facilitator/delegatedAuthStore"; +import { + BatchReceiverAuthorizerConflictError, + InMemoryBatchReceiverAuthorizerStore, +} from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import type { BatchReceiverBindingHistoryReader } from "../../src/batch-settlement/facilitator/types"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { + encodeReceiverBindingMemo, + readReceiverBindingFromOpen, +} from "../../src/batch-settlement/receiverBinding"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; + +let payer: Awaited>; +let feePayer: Awaited>; +let server: Awaited>; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + server = await generateKeyPairSigner(); +}); + +function openArgs(overrides: { bindingMemo?: string; memo?: string } = {}) { + return { + authorizedSigner: payer.address, + bindingMemo: encodeReceiverBindingMemo(server.address), + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + deposit: 10_000n, + feePayer: feePayer.address, + gracePeriod: 900, + mint: MINT, + openSlot: 123n, + payee: feePayer.address, + payer, + recipients: [{ bps: 10_000, recipient: RECEIVER }], + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + ...overrides, + }; +} + +function signer() { + return { + getAccountInfo: vi.fn(), + getAddresses: () => [feePayer.address], + getSigner: () => feePayer, + }; +} + +describe("readReceiverBindingFromOpen", () => { + it("reads the single binding memo on the channel's open", async () => { + const open = await buildOpenPaymentChannelTransaction(openArgs()); + expect(readReceiverBindingFromOpen(open.transaction, open.channelId)).toBe(server.address); + expect(readReceiverBindingFromOpen(open.transaction, payer.address)).toBeUndefined(); + expect(readReceiverBindingFromOpen("not-a-transaction", open.channelId)).toBeUndefined(); + }); + + it("returns undefined when the open has no binding memo or more than one", async () => { + const missing = await buildOpenPaymentChannelTransaction(openArgs({ bindingMemo: undefined })); + expect(readReceiverBindingFromOpen(missing.transaction, missing.channelId)).toBeUndefined(); + const doubled = await buildOpenPaymentChannelTransaction( + openArgs({ memo: encodeReceiverBindingMemo(payer.address) }), + ); + expect(readReceiverBindingFromOpen(doubled.transaction, doubled.channelId)).toBeUndefined(); + }); +}); + +describe("batch-settlement binding source", () => { + it("rejects a facilitator with neither a store nor a history reader", () => { + expect(() => new BatchSvmScheme(signer() as never)).toThrow(/receiverAuthorizerStore/); + expect( + () => + new BatchSvmScheme({ + ...signer(), + getSignaturesForAddress: vi.fn(), + getTransaction: vi.fn(), + } as never), + ).toThrow(/receiverAuthorizerStore/); + expect( + () => + new BatchSvmScheme(signer() as never, { + receiverBindingHistoryReader: { + getSignaturesForAddress: vi.fn(), + } as unknown as BatchReceiverBindingHistoryReader, + }), + ).toThrow(/getTransaction/); + }); + + it("resolves an open from history, skips a failed transaction, and writes the store back", async () => { + const open = await buildOpenPaymentChannelTransaction(openArgs()); + const store = new InMemoryBatchReceiverAuthorizerStore(); + const fetched: string[] = []; + const historyReader: BatchReceiverBindingHistoryReader = { + getSignaturesForAddress: async (_network, _address, options) => { + if (options?.before === undefined) { + return [ + ...Array.from({ length: 999 }, (_, index) => ({ + err: { InstructionError: [0, "Custom"] }, + signature: `failed-${index}`, + })), + { err: null, signature: "not-the-open" }, + ]; + } + expect(options.before).toBe("not-the-open"); + return [{ err: null, signature: "the-open" }]; + }, + getTransaction: async (_network, signature) => { + fetched.push(signature); + if (signature === "not-the-open") return missingMemo(); + return open.transaction; + }, + }; + await expect( + readReceiverAuthorizer(store, historyReader, NETWORK, open.channelId), + ).resolves.toBe(server.address); + expect(fetched).toEqual(["the-open"]); + expect((await store.get(NETWORK, open.channelId))?.receiverAuthorizer).toBe(server.address); + }); + + it("does not write a store row when the open has no single binding memo", async () => { + const missing = await buildOpenPaymentChannelTransaction(openArgs({ bindingMemo: undefined })); + const doubled = await buildOpenPaymentChannelTransaction( + openArgs({ memo: encodeReceiverBindingMemo(payer.address) }), + ); + let wire = missing.transaction; + const store = new InMemoryBatchReceiverAuthorizerStore(); + const historyReader: BatchReceiverBindingHistoryReader = { + getSignaturesForAddress: async () => [{ err: null, signature: "only" }], + getTransaction: async () => wire, + }; + + await expect( + readReceiverAuthorizer(store, historyReader, NETWORK, missing.channelId), + ).resolves.toBeUndefined(); + wire = doubled.transaction; + await expect( + readReceiverAuthorizer(store, historyReader, NETWORK, doubled.channelId), + ).resolves.toBeUndefined(); + expect(await store.get(NETWORK, missing.channelId)).toBeUndefined(); + expect(await store.get(NETWORK, doubled.channelId)).toBeUndefined(); + }); + + it("returns a store hit without consulting history", async () => { + const store = new InMemoryBatchReceiverAuthorizerStore(); + const channelId = "stored-channel"; + await store.bind({ channelId, network: NETWORK, receiverAuthorizer: server.address }); + const historyReader: BatchReceiverBindingHistoryReader = { + getSignaturesForAddress: vi.fn(async () => { + throw new Error("history should not run"); + }), + getTransaction: vi.fn(), + }; + + await expect(readReceiverAuthorizer(store, historyReader, NETWORK, channelId)).resolves.toBe( + server.address, + ); + expect(historyReader.getSignaturesForAddress).not.toHaveBeenCalled(); + }); + + it("maps a write-back conflict to RECEIVER_AUTHORIZER_MISMATCH and rethrows other bind errors", async () => { + const open = await buildOpenPaymentChannelTransaction(openArgs()); + const historyReader: BatchReceiverBindingHistoryReader = { + getSignaturesForAddress: async () => [{ err: null, signature: "open" }], + getTransaction: async () => open.transaction, + }; + const conflictStore = { + bind: vi.fn(async () => { + throw new BatchReceiverAuthorizerConflictError(); + }), + delete: vi.fn(), + get: vi.fn(async () => undefined), + }; + await expect( + readReceiverAuthorizer(conflictStore, historyReader, NETWORK, open.channelId), + ).rejects.toThrow(BatchError.RECEIVER_AUTHORIZER_MISMATCH); + + const failingStore = { + bind: vi.fn(async () => { + throw new Error("disk full"); + }), + delete: vi.fn(), + get: vi.fn(async () => undefined), + }; + await expect( + readReceiverAuthorizer(failingStore, historyReader, NETWORK, open.channelId), + ).rejects.toThrow(/disk full/); + }); + + it("validates binding-source and delegated-auth configuration", () => { + expect(assertDelegatedReceiverAuth(undefined)).toBeUndefined(); + const identityStore = new InMemoryBatchDelegatedAuthStore(); + const delegated = { + identityStore, + receiverAuthorizer: server.address, + resolveCallerIdentity: async () => "caller", + }; + expect(assertDelegatedReceiverAuth(delegated)).toBe(delegated); + + expect(() => + assertDelegatedReceiverAuth({ + ...delegated, + receiverAuthorizer: "not-a-key", + }), + ).toThrow(/receiverAuthorizer address/); + expect(() => + assertDelegatedReceiverAuth({ + ...delegated, + identityStore: { bind: vi.fn() } as never, + }), + ).toThrow(/identityStore must implement/); + + expect(() => + assertBindingSource({ + receiverAuthorizerStore: { bind: vi.fn() } as never, + }), + ).toThrow(/receiverAuthorizerStore must implement/); + expect(() => + assertBindingSource({ + receiverBindingHistoryReader: { getSignaturesForAddress: vi.fn() } as never, + }), + ).toThrow(/receiverBindingHistoryReader must implement/); + }); + + it("resolves delegated identity and recognizes the delegated authorizer key", async () => { + const delegated = { + identityStore: new InMemoryBatchDelegatedAuthStore(), + receiverAuthorizer: server.address, + resolveCallerIdentity: vi.fn(), + }; + delegated.resolveCallerIdentity.mockResolvedValueOnce("caller-a"); + await expect( + resolveDelegatedIdentity(delegated, { + channelId: "ch", + network: NETWORK, + payer: payer.address, + step: "deposit", + }), + ).resolves.toBe("caller-a"); + + delegated.resolveCallerIdentity.mockRejectedValueOnce(new Error("auth down")); + await expect( + resolveDelegatedIdentity(delegated, { + channelId: "ch", + network: NETWORK, + payer: payer.address, + step: "seal", + }), + ).resolves.toBeUndefined(); + + delegated.resolveCallerIdentity.mockResolvedValueOnce(""); + await expect( + resolveDelegatedIdentity(delegated, { + channelId: "ch", + network: NETWORK, + payer: payer.address, + step: "refund", + }), + ).resolves.toBeUndefined(); + + expect(isDelegatedAuthorizer(delegated, server.address)).toBe(true); + expect(isDelegatedAuthorizer(delegated, payer.address)).toBe(false); + expect(isDelegatedAuthorizer(undefined, server.address)).toBe(false); + }); +}); + +function missingMemo(): Promise { + return buildOpenPaymentChannelTransaction(openArgs({ bindingMemo: undefined })).then( + open => open.transaction, + ); +} diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.recovery.expiry.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.recovery.expiry.test.ts new file mode 100644 index 0000000000..d11821b4ed --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.recovery.expiry.test.ts @@ -0,0 +1,331 @@ +import { findAssociatedTokenPda } from "@solana-program/token-2022"; +import { + address, + appendTransactionMessageInstruction, + blockhash, + createTransactionMessage, + generateKeyPairSigner, + getBase64EncodedWireTransaction, + getSignatureFromTransaction, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signTransactionMessageWithSigners, + type Signature, +} from "@solana/kit"; +import type { PaymentPayload, PaymentRequirements, SettleResponse } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { BatchError } from "../../src/batch-settlement/errors"; +import { + broadcastExpiredWithoutLanding, + InMemoryBatchPendingSettlementStore, +} from "../../src/batch-settlement/facilitator/recovery"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import type { BatchChannelConfig, BatchSettlePayload } from "../../src/batch-settlement/types"; +import { + MEMO_PROGRAM_ADDRESS, + SOLANA_DEVNET_CAIP2, + TOKEN_PROGRAM_ADDRESS, +} from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { createRpcCapabilitiesFromRpc } from "../../src/signer"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const CHANNEL_ID = USDC_MAINNET_ADDRESS; +const BLOCKHASH = "11111111111111111111111111111111"; + +let payer: Awaited>; +let feePayer: Awaited>; +let wire: string; +let signature: string; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + const message = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(feePayer, m), + m => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: blockhash(BLOCKHASH), lastValidBlockHeight: 1n }, + m, + ), + m => + appendTransactionMessageInstruction( + { accounts: [], data: new Uint8Array([1]), programAddress: address(MEMO_PROGRAM_ADDRESS) }, + m, + ), + ); + const signed = await signTransactionMessageWithSigners(message); + wire = getBase64EncodedWireTransaction(signed); + signature = getSignatureFromTransaction(signed); +}); + +function requirements(payTo = USDC_MAINNET_ADDRESS): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { feePayer: feePayer.address, tokenProgram: TOKEN_PROGRAM_ADDRESS, withdrawDelay: 900 }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo, + scheme: "batch-settlement", + }; +} + +function channelConfig(): BatchChannelConfig { + return { + openSlot: 1, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: USDC_MAINNET_ADDRESS, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; +} + +function transport(overrides: Record = {}) { + return { + confirmTransaction: vi.fn().mockRejectedValue(new Error("Transaction confirmation timeout")), + getAccountInfo: vi.fn(), + getAddresses: vi.fn(() => [feePayer.address]), + getConfirmedTransaction: vi.fn().mockResolvedValue(null), + getSigner: vi.fn(() => feePayer), + isBlockhashValid: vi.fn().mockResolvedValue(false), + sendTransaction: vi.fn().mockResolvedValue(signature), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +type Internals = { + reconcileBroadcast( + key: string, + signature: string, + network: string, + payer: string, + ): Promise<{ ok: true } | { ok: false; response: SettleResponse }>; + broadcastDurably( + key: string, + network: string, + payer: string, + broadcast: (onPrepared: (signature: string, wire: string) => Promise) => Promise, + ): Promise; + resolveTerms: unknown; + deriveChannelId: unknown; + trackChannel: unknown; +}; + +const KEY = `batch:distribute:${NETWORK}:${MINT}:${USDC_MAINNET_ADDRESS}:${CHANNEL_ID}`; +const wireKey = () => `batch:transaction:${NETWORK}:${signature}:wire`; + +async function pendingScheme(signer: ReturnType, key = KEY) { + const store = new InMemoryBatchPendingSettlementStore(); + await store.set(key, signature); + await store.set(wireKey(), wire); + const scheme = new BatchSvmScheme(signer as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + return { internals: scheme as unknown as Internals, scheme, store }; +} + +describe("expiry of a broadcast that was never confirmed", () => { + it("releases the queue when the blockhash expired and the network has no record", async () => { + const signer = transport(); + const { internals, store } = await pendingScheme(signer); + + const result = await internals.reconcileBroadcast(KEY, signature, NETWORK, ""); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.response).toMatchObject({ + success: false, + errorReason: "transaction_failed", + transaction: signature, + }); + expect(result.response.errorMessage).toContain("expired"); + expect(signer.isBlockhashValid).toHaveBeenCalledWith(BLOCKHASH, NETWORK); + expect(signer.getConfirmedTransaction).toHaveBeenCalledWith(signature, NETWORK); + expect(await store.get(KEY)).toBeUndefined(); + expect(await store.get(wireKey())).toBeUndefined(); + // The stored bytes were resent once before their fate was decided. + expect(signer.sendTransaction).toHaveBeenCalledWith(wire, NETWORK); + }); + + it("stays pending while the blockhash is still valid", async () => { + const signer = transport({ isBlockhashValid: vi.fn().mockResolvedValue(true) }); + const { internals, store } = await pendingScheme(signer); + + const result = await internals.reconcileBroadcast(KEY, signature, NETWORK, ""); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.response.errorReason).toBe("settlement_pending"); + expect(signer.getConfirmedTransaction).not.toHaveBeenCalled(); + expect(await store.get(KEY)).toBe(signature); + expect(await store.get(wireKey())).toBe(wire); + }); + + it("stays pending when the expired blockhash's transaction is found in history", async () => { + const signer = transport({ + getConfirmedTransaction: vi.fn().mockResolvedValue({ slot: 5n, meta: null, transaction: {} }), + }); + const { internals, store } = await pendingScheme(signer); + + const result = await internals.reconcileBroadcast(KEY, signature, NETWORK, ""); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.response.errorReason).toBe("settlement_pending"); + expect(await store.get(KEY)).toBe(signature); + }); + + it("stays pending when the signer cannot check blockhash validity", async () => { + const signer = transport({ isBlockhashValid: undefined }); + const { internals, store } = await pendingScheme(signer); + + const result = await internals.reconcileBroadcast(KEY, signature, NETWORK, ""); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.response.errorReason).toBe("settlement_pending"); + expect(await store.get(KEY)).toBe(signature); + }); + + it("never classifies a broadcast as expired without its bytes or on a failing check", async () => { + const signer = transport(); + expect(await broadcastExpiredWithoutLanding(signer, signature, NETWORK, undefined)).toBe(false); + expect( + await broadcastExpiredWithoutLanding(signer, signature, NETWORK, "not-a-transaction"), + ).toBe(false); + const failing = transport({ + isBlockhashValid: vi.fn().mockRejectedValue(new Error("rpc down")), + }); + expect(await broadcastExpiredWithoutLanding(failing, signature, NETWORK, wire)).toBe(false); + expect(await broadcastExpiredWithoutLanding(transport(), signature, NETWORK, wire)).toBe(true); + }); +}); + +describe("ambiguous payout attribution", () => { + it("answers with its own reason and releases the sweep queue", async () => { + const payTo = payer.address; // merchant is also the channel's refund beneficiary + const key = `batch:distribute:${NETWORK}:${MINT}:${payTo}:${CHANNEL_ID}`; + const [recipient] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(payTo), + tokenProgram: address(TOKEN_PROGRAM_ADDRESS), + }); + const [escrow] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(CHANNEL_ID), + tokenProgram: address(TOKEN_PROGRAM_ADDRESS), + }); + const token = (accountIndex: number, owner: string, amount: string) => ({ + accountIndex, + mint: MINT, + owner, + uiTokenAmount: { amount }, + }); + const signer = transport({ + confirmTransaction: vi.fn().mockResolvedValue({ slot: 100n }), + getConfirmedTransaction: vi.fn().mockResolvedValue({ + slot: 100n, + transaction: { message: { accountKeys: [recipient, escrow] } }, + meta: { + err: null, + preTokenBalances: [token(0, payTo, "100"), token(1, CHANNEL_ID, "9800")], + // The escrow is gone: a sealed close refunded the payer, who is also the merchant. + postTokenBalances: [token(0, payTo, "9900")], + }, + }), + }); + const record = vi.fn(); + const store = new InMemoryBatchPendingSettlementStore(); + await store.set(key, signature); + const scheme = new BatchSvmScheme(signer as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + onDistributionConfirmed: record, + }); + const api = scheme as unknown as Internals; + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "client", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(CHANNEL_ID); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelId: CHANNEL_ID, channelConfig: channelConfig() }], + }; + const payment = { accepted: requirements(payTo), payload, x402Version: 2 } as PaymentPayload; + + const response = await scheme.settleDistributions(payment, payload, requirements(payTo)); + + expect(response).toMatchObject({ + success: false, + errorReason: BatchError.PAYOUT_ATTRIBUTION_AMBIGUOUS, + transaction: signature, + }); + expect(record).not.toHaveBeenCalled(); + // The queue is released: nothing pending, the outcome recorded by signature. + expect(await store.get(key)).toBeUndefined(); + expect(JSON.parse((await store.get(`${key}:result`))!)).toMatchObject({ + errorReason: BatchError.PAYOUT_ATTRIBUTION_AMBIGUOUS, + }); + }); +}); + +describe("wire records", () => { + it("drops the bytes written by a broadcast that lost its reservation", async () => { + const signer = transport(); + const store = new InMemoryBatchPendingSettlementStore(); + await store.set(KEY, "other-signature"); + const scheme = new BatchSvmScheme(signer as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = scheme as unknown as Internals & { reconcileBroadcast: unknown }; + api.reconcileBroadcast = vi + .fn() + .mockResolvedValue({ ok: true, replayed: false, signature: "other-signature" }); + + await api.broadcastDurably(KEY, NETWORK, "", async onPrepared => { + await onPrepared(signature, wire); + return signature; + }); + + expect(await store.get(wireKey())).toBeUndefined(); + expect(await store.get(KEY)).toBe("other-signature"); + }); +}); + +describe("confirmation polling", () => { + it("searches transaction history on the first lookup only", async () => { + const getSignatureStatuses = vi + .fn() + .mockReturnValueOnce({ send: async () => ({ value: [null] }) }) + .mockReturnValue({ + send: async () => ({ value: [{ slot: 7n, confirmationStatus: "confirmed", err: null }] }), + }); + const caps = createRpcCapabilitiesFromRpc({ getSignatureStatuses } as never); + + await caps.confirmTransaction("recover" as Signature, { searchTransactionHistory: true }); + + expect(getSignatureStatuses).toHaveBeenCalledTimes(2); + expect(getSignatureStatuses.mock.calls[0]).toEqual([ + ["recover"], + { searchTransactionHistory: true }, + ]); + expect(getSignatureStatuses.mock.calls[1]).toEqual([["recover"]]); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.recovery.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.recovery.test.ts new file mode 100644 index 0000000000..11cf05e83f --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.recovery.test.ts @@ -0,0 +1,783 @@ +import { findAssociatedTokenPda } from "@solana-program/token-2022"; +import { address, generateKeyPairSigner, type Signature } from "@solana/kit"; +import { InMemoryPendingSettlementStore } from "@x402/core/facilitator"; +import type { PaymentPayload, PaymentRequirements, SettleResponse } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { signBatchVoucher } from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { InMemoryBatchPendingSettlementStore } from "../../src/batch-settlement/facilitator/recovery"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { prepareRefund } from "../../src/batch-settlement/facilitator/seal"; +import type { + BatchChannelConfig, + BatchClaimPayload, + BatchRefundPayload, + BatchSettlePayload, +} from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import { getChannelDistributionHash } from "../../src/payment-channels/facilitator"; +import { ChannelStatus } from "../../src/payment-channels/onchain"; + +vi.mock("../../src/batch-settlement/facilitator/seal", async importOriginal => ({ + ...(await importOriginal()), + prepareRefund: vi.fn(), +})); + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const TX = USDC_DEVNET_ADDRESS as Signature; + +let payer: Awaited>; +let feePayer: Awaited>; +let channelId: string; +let channelConfig: BatchChannelConfig; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + channelId = USDC_MAINNET_ADDRESS; + channelConfig = { + openSlot: 1, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: RECEIVER, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; +}); + +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: RECEIVER, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function channel(overrides: Partial = {}): Channel { + return { + authorizedSigner: address(payer.address), + bump: 1, + closureStartedAt: 0n, + deposit: 10_000n, + discriminator: 1, + distributionHash: getChannelDistributionHash([{ bps: 10_000, recipient: RECEIVER }]), + gracePeriod: 900, + mint: address(MINT), + openSlot: 1n, + payee: address(feePayer.address), + payer: address(payer.address), + payerWithdrawnAt: 0n, + rentPayer: address(feePayer.address), + salt: 0n, + settlement: { payoutWatermark: 0n, settled: 0n }, + status: ChannelStatus.Open, + version: 1, + ...overrides, + }; +} + +function signer(confirmTransaction = vi.fn().mockResolvedValue(undefined)) { + return { + confirmTransaction, + getAccountInfo: vi.fn(), + getConfirmedTransaction: vi.fn(() => payoutEvidence()), + getAddresses: vi.fn(() => [feePayer.address]), + getSigner: vi.fn(() => feePayer), + sendTransaction: vi.fn().mockResolvedValue(TX), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + }; +} + +type RecoveryInternals = { + broadcastDurably: ReturnType; + completeOrPending( + key: string, + signature: string, + network: typeof NETWORK, + payer: string, + ): Promise; + deriveChannelId: ReturnType; + distributeInstruction: ReturnType; + fetchChannel: ReturnType; + fetchChannelsUntil: ReturnType; + fetchChannelUntil: ReturnType; + forgetPending(key: string): Promise; + readChannel: ReturnType; + reconcileBroadcast: ReturnType; + resolveTerms: ReturnType; + submitRedemption: ReturnType; + trackChannel: ReturnType; + waitForChannelRead: ReturnType; + settleRefund( + payment: PaymentPayload, + payload: BatchRefundPayload, + requirements: PaymentRequirements, + ): Promise; +}; + +function configure(scheme: BatchSvmScheme): RecoveryInternals { + const api = scheme as unknown as RecoveryInternals; + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "client", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + api.distributeInstruction = vi.fn().mockResolvedValue({ + accounts: [], + data: new Uint8Array([7]), + programAddress: address(RECEIVER), + }); + return api; +} + +async function claimPayload(amount = 1_000n): Promise { + const signed = await signBatchVoucher(payer, { + channelId, + expiresAt: 0, + maxClaimableAmount: amount, + }); + return { + claims: [ + { + channelConfig, + channelId, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: amount.toString(), + signature: signed.signature, + }, + }, + ], + type: "claim", + }; +} + +function payment(payload: unknown): PaymentPayload { + return { accepted: requirements(), payload, x402Version: 2 } as PaymentPayload; +} + +describe("batch-settlement outcome recovery", () => { + it("recovers a completed claim after cleanup without recreating its lifecycle record", async () => { + const payload = await claimPayload(); + const store = new InMemoryPendingSettlementStore(); + await store.set(`batch:claim:${NETWORK}:${channelId}:1000:completed`, TX); + for (let attempt = 0; attempt < 2; attempt++) { + const transport = signer(); + const restarted = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(restarted); + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.submitRedemption = vi.fn(); + expect(await restarted.settleClaims(payment(payload), payload, requirements())).toMatchObject( + { + success: true, + transaction: TX, + }, + ); + expect(api.trackChannel).not.toHaveBeenCalled(); + expect(api.readChannel).not.toHaveBeenCalled(); + expect(transport.getAccountInfo).not.toHaveBeenCalled(); + expect(transport.confirmTransaction).not.toHaveBeenCalled(); + expect(api.submitRedemption).not.toHaveBeenCalled(); + expect(transport.sendTransaction).not.toHaveBeenCalled(); + } + }); + + it("recovers a restart between preparation and broadcast using identical bytes", async () => { + const store = new InMemoryPendingSettlementStore(); + const key = `batch:claim:${NETWORK}:${channelId}:1000`; + await store.set(key, TX); + await store.set(`batch:transaction:${NETWORK}:${TX}:wire`, "original-signed-bytes"); + const transport = signer(vi.fn().mockResolvedValue({ slot: 123n })); + const restarted = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(restarted); + api.readChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 0n, settled: 1000n } })); + api.submitRedemption = vi.fn(); + const payload = await claimPayload(); + expect(await restarted.settleClaims(payment(payload), payload, requirements())).toMatchObject({ + success: true, + transaction: TX, + }); + expect(transport.sendTransaction).toHaveBeenCalledExactlyOnceWith( + "original-signed-bytes", + NETWORK, + ); + expect(api.submitRedemption).not.toHaveBeenCalled(); + }); + + it("constrains post-confirmation reads to the transaction slot and retries a lagging RPC", async () => { + const store = new InMemoryPendingSettlementStore(); + const key = `batch:claim:${NETWORK}:${channelId}:1000`; + await store.set(key, TX); + const transport = signer(vi.fn().mockResolvedValue({ slot: 321n })); + const scheme = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + api.waitForChannelRead = vi.fn(); + const { getChannelEncoder } = await import( + "../../src/payment-channels/generated/accounts/channel" + ); + transport.getAccountInfo + .mockRejectedValueOnce(new Error("Minimum context slot has not been reached")) + .mockResolvedValue({ + data: [ + Buffer.from( + getChannelEncoder().encode( + channel({ settlement: { payoutWatermark: 0n, settled: 1000n } }), + ), + ).toString("base64"), + "base64", + ], + }); + const payload = await claimPayload(); + expect(await scheme.settleClaims(payment(payload), payload, requirements())).toMatchObject({ + success: true, + transaction: TX, + }); + expect(transport.getAccountInfo).toHaveBeenCalledTimes(2); + for (const call of transport.getAccountInfo.mock.calls) + expect(call[2]).toMatchObject({ commitment: "confirmed", minContextSlot: 321n }); + }); + + it("keeps unknown transactions pending and never builds replacement bytes", async () => { + const store = new InMemoryPendingSettlementStore(); + const key = `batch:claim:${NETWORK}:${channelId}:1000`; + await store.set(key, TX); + await store.set(`batch:transaction:${NETWORK}:${TX}:wire`, "original-signed-bytes"); + const transport = signer(vi.fn().mockRejectedValue(new Error("history unavailable"))); + const scheme = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + api.submitRedemption = vi.fn(); + const payload = await claimPayload(); + for (let i = 0; i < 2; i++) { + expect(await scheme.settleClaims(payment(payload), payload, requirements())).toMatchObject({ + success: false, + transaction: TX, + errorReason: "settlement_pending", + }); + } + expect(await store.get(key)).toBe(TX); + expect(api.submitRedemption).not.toHaveBeenCalled(); + expect(new Set(transport.sendTransaction.mock.calls.map(call => call[0]))).toEqual( + new Set(["original-signed-bytes"]), + ); + }); + + it("retries a stale claim read after confirmation", async () => { + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = configure(scheme); + api.readChannel = vi + .fn() + .mockResolvedValueOnce(channel()) + .mockResolvedValueOnce(channel()) + .mockResolvedValue(channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })); + api.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: TX }); + const payload = await claimPayload(); + + await expect( + scheme.settleClaims(payment(payload), payload, requirements()), + ).resolves.toMatchObject({ + extra: { accepts: [{ channelId, totalClaimed: "1000" }] }, + success: true, + transaction: TX, + }); + expect(api.readChannel).toHaveBeenCalledTimes(3); + expect(api.submitRedemption).toHaveBeenCalledOnce(); + }); + + it("recovers a lost claim response after restart and never credits an unrelated watermark", async () => { + const payload = await claimPayload(); + const key = `batch:claim:${NETWORK}:${channelId}:1000`; + const store = new InMemoryPendingSettlementStore(); + await store.set(key, TX); + const confirm = vi.fn().mockResolvedValue(undefined); + const recovering = new BatchSvmScheme(signer(confirm) as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const recoveryApi = configure(recovering); + recoveryApi.readChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })); + recoveryApi.submitRedemption = vi.fn(); + + const recovered = await recovering.settleClaims(payment(payload), payload, requirements()); + expect(recovered).toMatchObject({ success: true, transaction: TX }); + expect(confirm).toHaveBeenCalledOnce(); + expect(recoveryApi.submitRedemption).not.toHaveBeenCalled(); + expect(await store.get(key)).toBeUndefined(); + expect(await store.get(`${key}:completed`)).toBe(TX); + + const restarted = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const restartedApi = configure(restarted); + restartedApi.readChannel = vi.fn(); + restartedApi.submitRedemption = vi.fn(); + await expect( + restarted.settleClaims(payment(payload), payload, requirements()), + ).resolves.toMatchObject({ extra: {}, success: true, transaction: TX }); + expect(restartedApi.readChannel).not.toHaveBeenCalled(); + expect(restartedApi.submitRedemption).not.toHaveBeenCalled(); + + const unrelated = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const unrelatedApi = configure(unrelated); + unrelatedApi.readChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 0n, settled: 2_000n } })); + unrelatedApi.submitRedemption = vi.fn(); + await expect(unrelated.settleClaims(payment(payload), payload, requirements())).rejects.toThrow( + BatchError.CUMULATIVE_AMOUNT_MISMATCH, + ); + expect(unrelatedApi.submitRedemption).not.toHaveBeenCalled(); + }); + + it("coalesces distributions across managers sharing the same reference store", async () => { + const store = new InMemoryBatchPendingSettlementStore(); + const key = `batch:distribute:${NETWORK}:${MINT}:${RECEIVER}:${channelId}`; + await store.set(key, TX); + const transport = signer(); + const first = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const second = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + configure(first); + configure(second); + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelId, channelConfig }], + }; + const results = await Promise.all( + [first, second].map(scheme => + scheme.settleDistributions(payment(payload), payload, requirements()), + ), + ); + expect(results[0]).toEqual(results[1]); + expect(transport.getConfirmedTransaction).toHaveBeenCalledOnce(); + }); + + it("does not delete a successor transaction when an older completion finishes late", async () => { + const store = new InMemoryBatchPendingSettlementStore(); + await store.set("key", "successor"); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + await api.completeOrPending("key", TX, NETWORK, payer.address); + expect(await store.get("key")).toBe("successor"); + }); + + it("recovers the actual payout after a lost response and restart without changing the request", async () => { + const payload: BatchSettlePayload = { + channels: [{ channelConfig, channelId }], + type: "settle", + }; + const key = `batch:distribute:${NETWORK}:${MINT}:${RECEIVER}:${channelId}`; + const store = new InMemoryPendingSettlementStore(); + await store.set(key, TX); + const firstSigner = signer(); + firstSigner.getConfirmedTransaction.mockResolvedValue(await payoutEvidence("200", "1700")); + const first = new BatchSvmScheme(firstSigner as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(first); + api.submitRedemption = vi.fn(); + const response = await first.settleDistributions(payment(payload), payload, requirements()); + expect(response).toEqual({ + amount: "1500", + extra: { channels: [channelId] }, + network: NETWORK, + payer: "", + success: true, + transaction: TX, + }); + expect(api.submitRedemption).not.toHaveBeenCalled(); + expect(await store.get(key)).toBeUndefined(); + + const restarted = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const restartedApi = configure(restarted); + restartedApi.readChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 1700n, settled: 1700n } })); + restartedApi.submitRedemption = vi.fn(); + expect(await restarted.settleDistributions(payment(payload), payload, requirements())).toEqual( + response, + ); + expect(restartedApi.submitRedemption).not.toHaveBeenCalled(); + + // The identical request can legitimately pay a later claim. No permanent + // request-body cache may suppress it or return the old transaction. + restartedApi.readChannel.mockResolvedValue( + channel({ settlement: { payoutWatermark: 1700n, settled: 2200n } }), + ); + restartedApi.submitRedemption.mockResolvedValue({ ok: true, signature: "second-transaction" }); + const next = await restarted.settleDistributions(payment(payload), payload, requirements()); + expect(next.transaction).toBe("second-transaction"); + expect(restartedApi.submitRedemption).toHaveBeenCalledOnce(); + }); + + it("waits for transaction metadata instead of estimating payout from a stale snapshot", async () => { + const transport = signer(); + transport.getConfirmedTransaction + .mockResolvedValueOnce(null as never) + .mockRejectedValueOnce(new Error("RPC catching up")) + .mockResolvedValue(await payoutEvidence("200", "9000")); + const scheme = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = configure(scheme); + api.fetchChannel = vi + .fn() + .mockResolvedValue(channel({ settlement: { payoutWatermark: 200n, settled: 1000n } })); + api.submitRedemption = vi.fn().mockResolvedValue({ ok: true, signature: TX }); + api.waitForChannelRead = vi.fn(); + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelId, channelConfig }], + }; + const result = await scheme.settleDistributions(payment(payload), payload, requirements()); + expect(result.amount).toBe("8800"); + expect(transport.getConfirmedTransaction).toHaveBeenCalledTimes(3); + }); + + it("retains the distribution when recording its payout fails", async () => { + const store = new InMemoryPendingSettlementStore(); + const key = `batch:distribute:${NETWORK}:${MINT}:${RECEIVER}:${channelId}`; + await store.set(key, TX); + const record = vi + .fn() + .mockRejectedValueOnce(new Error("ledger unavailable")) + .mockResolvedValue(undefined); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + onDistributionConfirmed: record, + }); + configure(scheme); + const payload: BatchSettlePayload = { + type: "settle", + channels: [{ channelId, channelConfig }], + }; + expect( + await scheme.settleDistributions(payment(payload), payload, requirements()), + ).toMatchObject({ success: false, errorReason: "settlement_pending", transaction: TX }); + expect(await store.get(key)).toBe(TX); + expect( + await scheme.settleDistributions(payment(payload), payload, requirements()), + ).toMatchObject({ success: true, transaction: TX, amount: "800" }); + expect(await store.get(key)).toBeUndefined(); + }); + + it("recovers request_close after stale reads and remains replayable after cleanup", async () => { + const payload: BatchRefundPayload = { + channelConfig, + transaction: "signed-close", + type: "refund", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1000", signature: "signature" }, + }; + const key = `batch:refund:${NETWORK}:${channelId}:signed-close`; + const store = new InMemoryPendingSettlementStore(); + await store.set(key, TX); + const recovering = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const recoveryApi = configure(recovering); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await recoveryApi.resolveTerms(channelConfig, requirements()), + }); + recoveryApi.readChannel = vi + .fn() + .mockResolvedValueOnce(channel()) + .mockResolvedValue(channel({ closureStartedAt: 20n, status: ChannelStatus.Closing })); + + await expect( + recoveryApi.settleRefund(payment(payload), payload, requirements()), + ).resolves.toMatchObject({ + extra: { channelState: { withdrawRequestedAt: 20 } }, + success: true, + transaction: TX, + }); + expect(recoveryApi.readChannel).toHaveBeenCalledTimes(2); + + const completedReplay = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const completedReplayApi = configure(completedReplay); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await completedReplayApi.resolveTerms(channelConfig, requirements()), + }); + completedReplayApi.readChannel = vi + .fn() + .mockResolvedValueOnce(channel()) + .mockResolvedValue(channel({ closureStartedAt: 20n, status: ChannelStatus.Closing })); + await expect( + completedReplayApi.settleRefund(payment(payload), payload, requirements()), + ).resolves.toMatchObject({ extra: {}, success: true, transaction: TX }); + expect(completedReplayApi.readChannel).toHaveBeenCalledTimes(2); + + const restarted = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const restartedApi = configure(restarted); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await restartedApi.resolveTerms(channelConfig, requirements()), + }); + restartedApi.readChannel = vi.fn().mockResolvedValue(undefined); + await expect( + restartedApi.settleRefund(payment(payload), payload, requirements()), + ).resolves.toMatchObject({ + extra: {}, + success: true, + transaction: TX, + }); + }); + + it("retains exact transaction identity when recovered postconditions are still stale", async () => { + const claim = await claimPayload(); + const distribute: BatchSettlePayload = { + channels: [{ channelConfig, channelId }], + type: "settle", + }; + const refund: BatchRefundPayload = { + channelConfig, + transaction: "signed-close", + type: "refund", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1000", signature: "signature" }, + }; + const store = new InMemoryPendingSettlementStore(); + await store.set(`batch:claim:${NETWORK}:${channelId}:1000`, TX); + await store.set(`batch:distribute:${NETWORK}:${MINT}:${RECEIVER}:${channelId}`, TX); + await store.set(`batch:refund:${NETWORK}:${channelId}:signed-close`, TX); + const transport = signer(); + transport.getConfirmedTransaction.mockResolvedValue(null as never); + const scheme = new BatchSvmScheme(transport as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + api.waitForChannelRead = vi.fn(); + api.reconcileBroadcast = vi.fn().mockResolvedValue({ + ok: true, + + signature: TX, + }); + api.fetchChannelsUntil = vi.fn().mockResolvedValue(undefined); + api.fetchChannelUntil = vi.fn().mockResolvedValue(false); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await api.resolveTerms(channelConfig, requirements()), + }); + + await expect(scheme.settleClaims(payment(claim), claim, requirements())).resolves.toMatchObject( + { + errorReason: "settlement_pending", + transaction: TX, + }, + ); + await expect( + scheme.settleDistributions(payment(distribute), distribute, requirements()), + ).resolves.toMatchObject({ errorReason: "settlement_pending", transaction: TX }); + await expect(api.settleRefund(payment(refund), refund, requirements())).resolves.toMatchObject({ + errorReason: "settlement_pending", + transaction: TX, + }); + }); + + it("propagates terminal reconciliation responses for every recovered operation", async () => { + const claim = await claimPayload(); + const distribute: BatchSettlePayload = { + channels: [{ channelConfig, channelId }], + type: "settle", + }; + const refund: BatchRefundPayload = { + channelConfig, + transaction: "signed-close", + type: "refund", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1000", signature: "signature" }, + }; + const store = new InMemoryPendingSettlementStore(); + await store.set(`batch:claim:${NETWORK}:${channelId}:1000`, TX); + await store.set(`batch:distribute:${NETWORK}:${MINT}:${RECEIVER}:${channelId}`, TX); + await store.set(`batch:refund:${NETWORK}:${channelId}:signed-close`, TX); + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + const failure = { + errorReason: "transaction_failed", + network: NETWORK, + success: false, + transaction: TX, + } as SettleResponse; + api.reconcileBroadcast = vi.fn().mockResolvedValue({ ok: false, response: failure }); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await api.resolveTerms(channelConfig, requirements()), + }); + + await expect(scheme.settleClaims(payment(claim), claim, requirements())).resolves.toBe(failure); + await expect( + scheme.settleDistributions(payment(distribute), distribute, requirements()), + ).resolves.toBe(failure); + await expect(api.settleRefund(payment(refund), refund, requirements())).resolves.toBe(failure); + }); + + it("handles a concurrent completion race without rebuilding or rebroadcasting", async () => { + const claim = await claimPayload(); + const distribute: BatchSettlePayload = { + channels: [{ channelConfig, channelId }], + type: "settle", + }; + const refund: BatchRefundPayload = { + channelConfig, + transaction: "signed-close", + type: "refund", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "1000", signature: "signature" }, + }; + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const api = configure(scheme); + api.fetchChannel = vi + .fn() + .mockResolvedValueOnce(channel()) + .mockResolvedValueOnce(channel({ settlement: { payoutWatermark: 0n, settled: 1_000n } })) + .mockResolvedValueOnce(channel()); + api.submitRedemption = vi.fn().mockResolvedValue({ ok: true, replayed: true, signature: TX }); + vi.mocked(prepareRefund).mockResolvedValue({ + channelId, + requestClose: "signed-close", + terms: await api.resolveTerms(channelConfig, requirements()), + }); + api.broadcastDurably = vi.fn().mockResolvedValue({ ok: true, replayed: true, signature: TX }); + + await expect(scheme.settleClaims(payment(claim), claim, requirements())).resolves.toMatchObject( + { + extra: {}, + transaction: TX, + }, + ); + await expect( + scheme.settleDistributions(payment(distribute), distribute, requirements()), + ).resolves.toMatchObject({ amount: "800", extra: {}, transaction: TX }); + await expect(api.settleRefund(payment(refund), refund, requirements())).resolves.toMatchObject({ + extra: {}, + transaction: TX, + }); + }); + + it("returns pending when completion storage fails and bounds postcondition polling", async () => { + const store = { + delete: vi.fn().mockRejectedValue(new Error("delete unavailable")), + get: vi.fn().mockResolvedValue(undefined), + set: vi.fn().mockRejectedValue(new Error("write unavailable")), + }; + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + pendingSettlementStore: store, + }); + const api = configure(scheme); + await expect(api.completeOrPending("key", TX, NETWORK, payer.address)).resolves.toMatchObject({ + errorReason: "settlement_pending", + transaction: TX, + }); + await expect(api.forgetPending("key")).resolves.toBeUndefined(); + + api.readChannel = vi.fn().mockResolvedValue(channel()); + api.waitForChannelRead = vi.fn().mockResolvedValue(undefined); + await expect(api.fetchChannelUntil(NETWORK, channelId, () => false)).resolves.toBe(false); + await expect( + api.fetchChannelsUntil(NETWORK, [channelId], () => false), + ).resolves.toBeUndefined(); + expect(api.waitForChannelRead).toHaveBeenCalledTimes(8); + }); +}); + +async function payoutEvidence(before = "200", after = "1000") { + const [recipient] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(RECEIVER), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const [escrow] = await findAssociatedTokenPda({ + mint: address(MINT), + owner: address(channelId), + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + const token = (accountIndex: number, owner: string, amount: string) => ({ + accountIndex, + mint: MINT, + owner, + uiTokenAmount: { amount }, + }); + return { + slot: 100n, + transaction: { message: { accountKeys: [recipient, escrow] } }, + meta: { + err: null, + preTokenBalances: [token(0, RECEIVER, before), token(1, channelId, "9800")], + postTokenBalances: [token(0, RECEIVER, after), token(1, channelId, "9000")], + }, + }; +} diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.refund.edges.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.refund.edges.test.ts new file mode 100644 index 0000000000..2a49999ebb --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.refund.edges.test.ts @@ -0,0 +1,152 @@ +import { encodePaymentRequiredHeader, encodePaymentResponseHeader } from "@x402/core/http"; +import type { PaymentRequirements } from "@x402/core/types"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + probeBatchRequirements, + refundBatchChannel, +} from "../../src/batch-settlement/client/refund"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +function requirements(scheme = "batch-settlement"): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: USDC_MAINNET_ADDRESS, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: USDC_MAINNET_ADDRESS, + scheme, + }; +} + +const refundPayload = { + channelConfig: { + openSlot: 1, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: USDC_MAINNET_ADDRESS, + salt: "0", + token: USDC_DEVNET_ADDRESS, + withdrawDelay: 900, + }, + type: "refund" as const, + voucher: { + channelId: USDC_MAINNET_ADDRESS, + expiresAt: 0, + maxClaimableAmount: "0", + signature: "sig", + }, +}; + +const build = async (x402Version: number) => ({ payload: refundPayload, x402Version }); + +/** A fetch that answers every call with the same response. */ +function respond(status: number, headers: Record = {}): typeof fetch { + return (async () => new Response(null, { headers, status })) as unknown as typeof fetch; +} + +describe("batch-settlement refund driver edge cases", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("rejects a 402 probe that carries no PAYMENT-REQUIRED header", async () => { + await expect(probeBatchRequirements("https://example.test/paid", respond(402))).rejects.toThrow( + /no PAYMENT-REQUIRED header/, + ); + }); + + it("selects the Solana accept when an EVM batch-settlement accept is listed first", async () => { + const evm = { + ...requirements(), + extra: {}, + network: "eip155:84532", + }; + const header = encodePaymentRequiredHeader({ + accepts: [evm, requirements()], + x402Version: 2, + }); + await expect( + probeBatchRequirements( + "https://example.test/paid", + respond(402, { "PAYMENT-REQUIRED": header }), + ), + ).resolves.toMatchObject({ + requirements: { + extra: { feePayer: USDC_MAINNET_ADDRESS }, + network: SOLANA_DEVNET_CAIP2, + }, + }); + }); + + it("rejects a route that advertises no batch-settlement accept", async () => { + const header = encodePaymentRequiredHeader({ + accepts: [requirements("exact")], + x402Version: 2, + }); + await expect( + probeBatchRequirements( + "https://example.test/paid", + respond(402, { "PAYMENT-REQUIRED": header }), + ), + ).rejects.toThrow(/does not offer batch-settlement/); + }); + + it("falls back to globalThis.fetch when no fetch is supplied", async () => { + const settlement = { + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "close-signature", + }; + globalThis.fetch = respond(200, { + "PAYMENT-RESPONSE": encodePaymentResponseHeader(settlement), + }); + await expect( + refundBatchChannel(build, "https://example.test/paid", { requirements: requirements() }), + ).resolves.toMatchObject({ success: true, transaction: "close-signature" }); + }); + + it("fails clearly when no fetch implementation exists at all", async () => { + globalThis.fetch = undefined as unknown as typeof fetch; + await expect(refundBatchChannel(build, "https://example.test/paid")).rejects.toThrow( + /requires a fetch implementation/, + ); + }); + + it("reports a refusal without a reason when the 402 carries no header", async () => { + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: respond(402), + requirements: requirements(), + }), + ).rejects.toThrow(/refund refused: no reason given/); + }); + + it("reports a refusal whose 402 header carries no error field", async () => { + const header = encodePaymentRequiredHeader({ accepts: [requirements()], x402Version: 2 }); + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: respond(402, { "PAYMENT-REQUIRED": header }), + requirements: requirements(), + }), + ).rejects.toThrow(/refund refused: no reason given/); + }); + + it("rejects a non-402 response that lacks a PAYMENT-RESPONSE header", async () => { + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: respond(500), + requirements: requirements(), + }), + ).rejects.toThrow(/no PAYMENT-RESPONSE header \(status 500\)/); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.refund.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.refund.test.ts new file mode 100644 index 0000000000..546869e382 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.refund.test.ts @@ -0,0 +1,245 @@ +import { + encodePaymentRequiredHeader, + encodePaymentResponseHeader, + decodePaymentSignatureHeader, +} from "@x402/core/http"; +import type { PaymentRequirements } from "@x402/core/types"; +import { describe, expect, it } from "vitest"; + +import { + refundBatchChannel, + type RefundPayloadOptions, +} from "../../src/batch-settlement/client/refund"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const FEE_PAYER = USDC_MAINNET_ADDRESS; + +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: FEE_PAYER, + receiverAuthorizer: USDC_MAINNET_ADDRESS, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: USDC_MAINNET_ADDRESS, + scheme: "batch-settlement", + }; +} + +/** A close payload the driver should carry verbatim. */ +const closePayload = { + channelConfig: { + openSlot: 1, + payer: USDC_DEVNET_ADDRESS, + payerAuthorizer: USDC_DEVNET_ADDRESS, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: USDC_MAINNET_ADDRESS, + salt: "0", + token: USDC_DEVNET_ADDRESS, + withdrawDelay: 900, + }, + type: "refund" as const, + voucher: { + channelId: USDC_MAINNET_ADDRESS, + expiresAt: 0, + maxClaimableAmount: "3000", + signature: "voucher-signature", + }, +}; + +const build = async ( + x402Version: number, + _requirements: PaymentRequirements, + options?: RefundPayloadOptions, +) => ({ + payload: options?.withTransaction + ? { ...closePayload, transaction: "request-close-transaction" } + : closePayload, + x402Version, +}); + +describe("batch-settlement refund driver", () => { + it("probes the route, sends the close, and returns the settlement", async () => { + const sent: { url: string; headers: Record }[] = []; + const settlement = { + network: SOLANA_DEVNET_CAIP2, + payer: USDC_DEVNET_ADDRESS, + success: true, + transaction: "close-signature", + }; + const fetchImpl = (async (url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + sent.push({ headers, url }); + if (!headers["PAYMENT-SIGNATURE"]) { + // The unpaid probe advertises the terms the channel was opened against. + return new Response(null, { + headers: { + "PAYMENT-REQUIRED": encodePaymentRequiredHeader({ + accepts: [requirements()], + x402Version: 2, + }), + }, + status: 402, + }); + } + return new Response(null, { + headers: { "PAYMENT-RESPONSE": encodePaymentResponseHeader(settlement) }, + status: 200, + }); + }) as unknown as typeof fetch; + + await expect( + refundBatchChannel(build, "https://example.test/paid", { fetch: fetchImpl }), + ).resolves.toMatchObject({ success: true, transaction: "close-signature" }); + + // The close travels as a payment payload against the probed requirements. + expect(sent).toHaveLength(2); + const decoded = decodePaymentSignatureHeader(sent[1]!.headers["PAYMENT-SIGNATURE"]!); + expect(decoded).toMatchObject({ + accepted: { scheme: "batch-settlement" }, + payload: { type: "refund", voucher: { maxClaimableAmount: "3000" } }, + x402Version: 2, + }); + }); + + it("binds the close to client-signed requirements when the probe lists server-signed first", async () => { + const operator = USDC_MAINNET_ADDRESS; + const serverSigned = { + ...requirements(), + extra: { + ...requirements().extra, + operator, + voucherSigner: "server" as const, + }, + }; + const clientSigned = { + ...requirements(), + extra: { ...requirements().extra, voucherSigner: "client" as const }, + }; + const sent: ReturnType[] = []; + const fetchImpl = (async (url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + if (!headers["PAYMENT-SIGNATURE"]) { + return new Response(null, { + headers: { + "PAYMENT-REQUIRED": encodePaymentRequiredHeader({ + accepts: [serverSigned, clientSigned], + x402Version: 2, + }), + }, + status: 402, + }); + } + sent.push(decodePaymentSignatureHeader(headers["PAYMENT-SIGNATURE"]!)); + return new Response(null, { + headers: { + "PAYMENT-RESPONSE": encodePaymentResponseHeader({ + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "close-signature", + }), + }, + status: 200, + }); + }) as unknown as typeof fetch; + + await expect( + refundBatchChannel(build, "https://example.test/paid", { fetch: fetchImpl }), + ).resolves.toMatchObject({ success: true }); + + expect(sent[0]?.accepted).toEqual(clientSigned); + }); + + it("skips the probe when the caller already holds the requirements", async () => { + let calls = 0; + const fetchImpl = (async () => { + calls += 1; + return new Response(null, { + headers: { + "PAYMENT-RESPONSE": encodePaymentResponseHeader({ + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "close-signature", + }), + }, + status: 200, + }); + }) as unknown as typeof fetch; + + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: fetchImpl, + requirements: requirements(), + }), + ).resolves.toMatchObject({ success: true }); + expect(calls).toBe(1); + }); + + it("resends with a request_close when the facilitator has no binding", async () => { + const payloads: Record[] = []; + const fetchImpl = (async (_url: string, init?: RequestInit) => { + const headers = (init?.headers ?? {}) as Record; + payloads.push(decodePaymentSignatureHeader(headers["PAYMENT-SIGNATURE"]!).payload); + if (payloads.length === 1) { + const body = encodePaymentRequiredHeader({ + accepts: [requirements()], + error: BatchError.RECEIVER_BINDING_UNAVAILABLE, + x402Version: 2, + }); + return new Response(null, { headers: { "PAYMENT-REQUIRED": body }, status: 402 }); + } + return new Response(null, { + headers: { + "PAYMENT-RESPONSE": encodePaymentResponseHeader({ + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "request-close-signature", + }), + }, + status: 200, + }); + }) as unknown as typeof fetch; + + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: fetchImpl, + requirements: requirements(), + }), + ).resolves.toMatchObject({ success: true, transaction: "request-close-signature" }); + expect(payloads).toHaveLength(2); + expect(payloads[0]).not.toHaveProperty("transaction"); + expect(payloads[1]).toMatchObject({ transaction: "request-close-transaction" }); + }); + + it("surfaces the server's reason when the close is refused", async () => { + const fetchImpl = (async () => { + const body = encodePaymentRequiredHeader({ + accepts: [requirements()], + error: "invalid_batch_settlement_svm_close_state", + x402Version: 2, + }); + return new Response(null, { headers: { "PAYMENT-REQUIRED": body }, status: 402 }); + }) as unknown as typeof fetch; + + await expect( + refundBatchChannel(build, "https://example.test/paid", { + fetch: fetchImpl, + requirements: requirements(), + }), + ).rejects.toThrow(/invalid_batch_settlement_svm_close_state/); + }); + + it("refuses a route that does not answer with a 402", async () => { + const fetchImpl = (async () => new Response(null, { status: 200 })) as unknown as typeof fetch; + await expect( + refundBatchChannel(build, "https://example.test/open", { fetch: fetchImpl }), + ).rejects.toThrow(/expected 402/); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.rentCleanup.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.rentCleanup.test.ts new file mode 100644 index 0000000000..a2c04a5510 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.rentCleanup.test.ts @@ -0,0 +1,1108 @@ +import { AccountRole, generateKeyPairSigner } from "@solana/kit"; +import type { Address, TransactionSigner } from "@solana/kit"; +import type { Network } from "@x402/core/types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; +import { AccountDiscriminator } from "../../src/payment-channels/generated/types/accountDiscriminator"; +import { + buildReclaimInstruction, + ChannelStatus, + RECLAIM_DISCRIMINATOR, +} from "../../src/payment-channels/onchain"; +import { SEAL_DISCRIMINATOR } from "../../src/payment-channels/generated/instructions/seal"; +import { OPEN_SLOT_WINDOW } from "../../src/payment-channels/open"; +import { + DEFAULT_MAX_IDLE_SECS, + MAX_SAFE_RECLAIMS_PER_TX, + BatchSvmRentCleanupManager, +} from "../../src/batch-settlement/facilitator/rentCleanupManager"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { + InMemoryPaymentChannelStorage, + type PaymentChannelRecord, +} from "../../src/payment-channels/storage"; +import type { FacilitatorSvmSigner } from "../../src/signer"; +import { toFacilitatorSvmSigner } from "../../src/signer"; + +const NETWORK = SOLANA_DEVNET_CAIP2 as Network; +const OPEN_SLOT = 100n; +const CURRENT_SLOT_READY = OPEN_SLOT + OPEN_SLOT_WINDOW + 1n; +const CURRENT_SLOT_TOO_EARLY = OPEN_SLOT + OPEN_SLOT_WINDOW; +const FAR_FUTURE = 4_102_444_800; + +const fetchMaybeChannelMock = vi.hoisted(() => vi.fn()); +const submitChannelMock = vi.hoisted(() => vi.fn()); +const buildDistributeMock = vi.hoisted(() => vi.fn()); +const getSlotMock = vi.hoisted(() => vi.fn()); +const discoverChannelsMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../src/payment-channels/discovery", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/discovery", + ); + return { + ...actual, + discoverChannelsByRentPayer: discoverChannelsMock, + }; +}); + +vi.mock("../../src/payment-channels/generated/accounts/channel", async () => { + const actual = await vi.importActual< + typeof import("../../src/payment-channels/generated/accounts/channel") + >("../../src/payment-channels/generated/accounts/channel"); + return { + ...actual, + fetchMaybeChannel: fetchMaybeChannelMock, + }; +}); + +vi.mock("../../src/payment-channels/facilitator", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/facilitator", + ); + return { + ...actual, + submitChannelTransactionWithSigner: submitChannelMock, + }; +}); + +vi.mock("../../src/payment-channels/onchain", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/onchain", + ); + return { + ...actual, + buildDistributeInstruction: buildDistributeMock, + }; +}); + +/** + * Facilitator signer whose slot reads are the test double. Cleanup no longer + * builds its own RPC client, so `getSlot` has to live on the signer. + * + * @param feePayer - Key this facilitator can sign with + * @returns Signer double bound to {@link getSlotMock} + */ +function cleanupSigner( + feePayer: Awaited>, +): FacilitatorSvmSigner { + return { + ...toFacilitatorSvmSigner(feePayer), + getSlot: () => getSlotMock(), + }; +} + +describe("payment-channel reclaim primitive", () => { + it("exports OPEN_SLOT_WINDOW and builds reclaim with disc 9", async () => { + expect(OPEN_SLOT_WINDOW).toBe(1_500n); + expect(AccountDiscriminator.Channel).toBe(1); + expect(ChannelStatus.Open).toBe(0); + expect(ChannelStatus.Sealed).toBe(1); + expect(ChannelStatus.Closing).toBe(2); + expect(ChannelStatus.Distributed).toBe(3); + + const channel = await generateKeyPairSigner(); + const rentPayer = await generateKeyPairSigner(); + const ix = buildReclaimInstruction({ + channelId: channel.address, + rentPayer: rentPayer.address, + }); + expect(ix.data[0]).toBe(RECLAIM_DISCRIMINATOR); + expect(ix.accounts).toHaveLength(2); + expect(ix.accounts[0]?.role).toBe(AccountRole.WRITABLE); + expect(ix.accounts[1]?.role).toBe(AccountRole.WRITABLE); + }); +}); + +describe("Batch channel storage + scheme wiring", () => { + it("upserts on verify success, retains after settle, deletes when PDA gone", async () => { + const feePayer = await generateKeyPairSigner(); + const channel = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + const storage = new InMemoryPaymentChannelStorage(); + const scheme = new BatchSvmScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + channelStorage: storage, + }); + + const record: PaymentChannelRecord = { + channelId: channel.address, + payTo: payTo.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + firstSeenAt: Date.now() - 10_000, + expiresAt: FAR_FUTURE, + network: NETWORK, + }; + await storage.upsert(record); + expect(await storage.get(record.channelId)).toMatchObject({ + payTo: record.payTo, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + + const firstSeenAt = (await storage.get(record.channelId))!.firstSeenAt; + await storage.upsert({ ...record, firstSeenAt: Date.now(), expiresAt: FAR_FUTURE - 100 }); + expect((await storage.get(record.channelId))!.firstSeenAt).toBe(firstSeenAt); + expect((await storage.get(record.channelId))!.expiresAt).toBe(FAR_FUTURE); + + const manager = scheme.createRentCleanupManager(NETWORK); + fetchMaybeChannelMock.mockResolvedValue({ exists: false }); + await manager.cleanup(); + expect(await storage.get(record.channelId)).toBeUndefined(); + }); + + it("createRentCleanupManager returns a manager bound to the scheme storage", async () => { + const feePayer = await generateKeyPairSigner(); + const storage = new InMemoryPaymentChannelStorage(); + const manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: cleanupSigner(feePayer), + storage, + }); + expect(manager).toBeInstanceOf(BatchSvmRentCleanupManager); + expect(storage).toBeInstanceOf(InMemoryPaymentChannelStorage); + }); +}); + +describe("BatchSvmRentCleanupManager — cleanup", () => { + let feePayer: Awaited>; + let payer: Awaited>; + let payTo: Awaited>; + let storage: InMemoryPaymentChannelStorage; + let manager: BatchSvmRentCleanupManager; + + beforeEach(async () => { + vi.clearAllMocks(); + feePayer = await generateKeyPairSigner(); + payer = await generateKeyPairSigner(); + payTo = await generateKeyPairSigner(); + storage = new InMemoryPaymentChannelStorage(); + manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: cleanupSigner(feePayer), + storage, + }); + submitChannelMock.mockResolvedValue("Sig11111111111111111111111111111111111111111"); + buildDistributeMock.mockResolvedValue({ + accounts: [], + data: new Uint8Array([7]), + programAddress: "CHNLxYvVA28MJP9PrFuDXccuoGXAx7jBacfLEkahyGsX", + }); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + }); + + /** + * @param overrides - Partial live channel account fields + */ + function channelAccount(overrides: { + status: ChannelStatus; + openSlot?: bigint; + payee?: string; + rentPayer?: string; + payer?: string; + mint?: string; + closureStartedAt?: bigint; + gracePeriod?: number; + }) { + return { + exists: true as const, + data: { + status: overrides.status, + openSlot: overrides.openSlot ?? OPEN_SLOT, + payee: overrides.payee ?? feePayer.address, + rentPayer: overrides.rentPayer ?? feePayer.address, + payer: overrides.payer ?? payer.address, + mint: overrides.mint ?? USDC_DEVNET_ADDRESS, + closureStartedAt: overrides.closureStartedAt ?? 0n, + gracePeriod: overrides.gracePeriod ?? 900, + }, + }; + } + + /** + * @param overrides - Record field overrides + */ + async function seed(overrides: Partial = {}) { + const channel = overrides.channelId + ? { address: overrides.channelId } + : await generateKeyPairSigner(); + const record: PaymentChannelRecord = { + channelId: channel.address, + payTo: overrides.payTo ?? payTo.address, + tokenProgram: overrides.tokenProgram ?? TOKEN_PROGRAM_ADDRESS, + firstSeenAt: overrides.firstSeenAt ?? Date.now() - 7_200_000, + expiresAt: overrides.expiresAt ?? FAR_FUTURE, + lastActivityAt: overrides.lastActivityAt ?? overrides.firstSeenAt ?? Date.now() - 7_200_000, + network: overrides.network ?? NETWORK, + }; + await storage.upsert(record); + return record; + } + + // Batch-settlement channels record `expiresAt = 0` (non-expiring vouchers), + // so abandonment is governed by the advertised idle window instead. + describe("non-expiring channels and the idle window", () => { + const DAY_MS = 86_400_000; + + it("keeps an Open channel with recent lifecycle activity", async () => { + const record = await seed({ + firstSeenAt: Date.now() - 30 * DAY_MS, + lastActivityAt: Date.now() - DAY_MS, + expiresAt: 0, + }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Open })); + + const onClose = vi.fn(); + await manager.cleanup({ onClose }); + expect(onClose).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + expect(await storage.get(record.channelId)).toBeDefined(); + }); + + it("abandon-closes an Open channel idle for the default window", async () => { + const record = await seed({ + firstSeenAt: Date.now() - 30 * DAY_MS, + lastActivityAt: Date.now() - (DEFAULT_MAX_IDLE_SECS * 1_000 + DAY_MS), + expiresAt: 0, + }); + fetchMaybeChannelMock + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Open })) + .mockResolvedValueOnce({ exists: false }); + + const onClose = vi.fn(); + await manager.cleanup({ onClose }); + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ channelId: record.channelId, action: "abandon_close" }), + ); + expect(submitChannelMock).toHaveBeenCalledTimes(1); + }); + + it("honours a per-pass idle window and a zero window disables idle cleanup", async () => { + await seed({ + firstSeenAt: Date.now() - 30 * DAY_MS, + lastActivityAt: Date.now() - 2 * DAY_MS, + expiresAt: 0, + }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Open })); + + await manager.cleanup({ maxIdleSecs: 0 }); + expect(submitChannelMock).not.toHaveBeenCalled(); + + fetchMaybeChannelMock + .mockReset() + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Open })) + .mockResolvedValueOnce({ exists: false }); + const onClose = vi.fn(); + await manager.cleanup({ maxIdleSecs: 86_400, onClose }); + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ action: "abandon_close" })); + }); + + it("falls back to firstSeenAt when a record predates activity tracking", async () => { + await seed({ + firstSeenAt: Date.now() - (DEFAULT_MAX_IDLE_SECS * 1_000 + DAY_MS), + lastActivityAt: 0, + expiresAt: 0, + }); + fetchMaybeChannelMock + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Open })) + .mockResolvedValueOnce({ exists: false }); + + const onClose = vi.fn(); + await manager.cleanup({ onClose }); + expect(onClose).toHaveBeenCalledWith(expect.objectContaining({ action: "abandon_close" })); + }); + }); + + it("does not abandon-close Open channels before expiry plus grace", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const record = await seed({ + firstSeenAt: Date.now() - 60_000, + expiresAt: nowSecs + 300, + }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Open })); + + const onClose = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, onClose }); + expect(onClose).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + expect(await storage.get(record.channelId)).toBeDefined(); + }); + + it("abandon-closes Open channels after expiry plus grace", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const record = await seed({ + firstSeenAt: Date.now() - 60_000, + expiresAt: nowSecs - 200, + }); + fetchMaybeChannelMock + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Open })) + .mockResolvedValueOnce({ exists: false }); + + const onClose = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, onClose }); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ channelId: record.channelId, action: "abandon_close" }), + ); + expect(submitChannelMock).toHaveBeenCalledTimes(1); + const instructions = submitChannelMock.mock.calls[0]![3] as unknown[]; + expect(instructions.length).toBeGreaterThanOrEqual(2); + expect(await storage.get(record.channelId)).toBeUndefined(); + }); + + it("does not abandon-close Open channels before expiresAt even when firstSeen is old", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const record = await seed({ + firstSeenAt: Date.now() - 7_200_000, + expiresAt: nowSecs + 300, + }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Open })); + + const onClose = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, onClose }); + + expect(onClose).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + expect(await storage.get(record.channelId)).toBeDefined(); + }); + + it("distributes Sealed channels", async () => { + const record = await seed(); + fetchMaybeChannelMock + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Sealed })) + .mockResolvedValueOnce({ exists: false }); + + const onClose = vi.fn(); + await manager.cleanup({ onClose }); + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ channelId: record.channelId, action: "distribute" }), + ); + const instructions = submitChannelMock.mock.calls[0]![3] as unknown[]; + expect(instructions).toHaveLength(1); + }); + + it("defers Closing channels until the onchain grace period elapses", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + await seed(); + fetchMaybeChannelMock.mockResolvedValue( + channelAccount({ + status: ChannelStatus.Closing, + closureStartedAt: BigInt(nowSecs - 30), + gracePeriod: 60, + }), + ); + const onClose = vi.fn(); + const onReclaim = vi.fn(); + await manager.cleanup({ onClose, onReclaim }); + expect(onClose).not.toHaveBeenCalled(); + expect(onReclaim).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + }); + + it("seals and distributes Closing channels after the onchain grace period", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const record = await seed(); + fetchMaybeChannelMock + .mockResolvedValueOnce( + channelAccount({ + status: ChannelStatus.Closing, + closureStartedAt: BigInt(nowSecs - 60), + gracePeriod: 60, + }), + ) + .mockResolvedValueOnce({ exists: false }); + + const onClose = vi.fn(); + await manager.cleanup({ onClose }); + + expect(onClose).toHaveBeenCalledWith( + expect.objectContaining({ channelId: record.channelId, action: "forced_close" }), + ); + const instructions = submitChannelMock.mock.calls[0]![3] as { data: Uint8Array }[]; + expect(instructions).toHaveLength(2); + expect(instructions[0]?.data[0]).toBe(SEAL_DISCRIMINATOR); + expect(await storage.get(record.channelId)).toBeUndefined(); + }); + + it("defers Distributed reclaim until the open-slot gate elapses", async () => { + await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_TOO_EARLY); + + const onReclaim = vi.fn(); + await manager.cleanup({ onReclaim }); + expect(onReclaim).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + }); + + it("batch-reclaims Distributed channels after the open-slot gate", async () => { + const a = await seed(); + const b = await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 8, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(1); + expect(onReclaim.mock.calls[0]![0].channelIds).toEqual( + expect.arrayContaining([a.channelId, b.channelId]), + ); + const instructions = submitChannelMock.mock.calls[0]![3] as { data: Uint8Array }[]; + expect(instructions).toHaveLength(2); + expect(instructions.every(ix => ix.data[0] === RECLAIM_DISCRIMINATOR)).toBe(true); + // Reclaim batches carry a per-channel compute-unit limit (base + 2 × per-channel). + expect(submitChannelMock.mock.calls[0]![4]).toMatchObject({ computeUnitLimit: 35_000 }); + expect(await storage.get(a.channelId)).toBeUndefined(); + expect(await storage.get(b.channelId)).toBeUndefined(); + }); + + // A failed batch strands every channel in it, so reporting only the first + // would hide the rest from the operator watching onError. + it("reports every channel in a reclaim batch that failed to broadcast", async () => { + const a = await seed(); + const b = await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + submitChannelMock.mockRejectedValue(new Error("broadcast failed")); + + const onError = vi.fn(); + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 8, onError, onReclaim }); + + expect(onReclaim).not.toHaveBeenCalled(); + expect(onError.mock.calls.map(call => call[1]?.channelId)).toEqual( + expect.arrayContaining([a.channelId, b.channelId]), + ); + }); + + it("respects maxReclaimsPerTx and maxTxsPerSigner for reclaim batching", async () => { + await seed(); + await seed(); + await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 2, maxTxsPerSigner: 1, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(1); + expect(onReclaim.mock.calls[0]![0].channelIds).toHaveLength(2); + expect(submitChannelMock).toHaveBeenCalledTimes(1); + }); + + // An operator-configured maxReclaimsPerTx above MAX_SAFE_RECLAIMS_PER_TX + // must be clamped, not honored: a larger batch risks failing to serialize + // or being rejected on broadcast (see the Go SDK's + // TestReclaimBatchFitsInOneTransaction, which proves the same ceiling). + it("clamps maxReclaimsPerTx to MAX_SAFE_RECLAIMS_PER_TX", async () => { + for (let i = 0; i < MAX_SAFE_RECLAIMS_PER_TX + 1; i++) { + await seed(); + } + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 1_000, maxTxsPerSigner: 10, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(2); + const batchSizes = onReclaim.mock.calls.map(call => call[0].channelIds.length as number); + expect(Math.max(...batchSizes)).toBe(MAX_SAFE_RECLAIMS_PER_TX); + expect(batchSizes.reduce((a, b) => a + b, 0)).toBe(MAX_SAFE_RECLAIMS_PER_TX + 1); + }); + + // A misconfigured non-positive maxReclaimsPerTx must fall back to the + // default instead of spinning the batching loop forever (`i += 0`). + it("falls back to the default when maxReclaimsPerTx is non-positive", async () => { + await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 0, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(1); + }); + + // The two budgets bound different things: maxTxsPerRun stops the storage + // scan, maxTxsPerSigner caps each rent payer's reclaims. A scan budget of 1 + // must not also throttle reclaims, which cost the scan nothing to classify. + it("budgets the scan and reclaims separately", async () => { + await seed(); + await seed(); + await seed(); + await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Distributed })); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 1, maxTxsPerRun: 1, maxTxsPerSigner: 4, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(4); + }); + + it("skips reclaim when a concurrent settle already changed status (stale refetch)", async () => { + const record = await seed(); + fetchMaybeChannelMock + .mockResolvedValueOnce(channelAccount({ status: ChannelStatus.Distributed })) + .mockResolvedValueOnce({ exists: false }); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ onReclaim }); + expect(onReclaim).not.toHaveBeenCalled(); + expect(submitChannelMock).not.toHaveBeenCalled(); + expect(await storage.get(record.channelId)).toBeUndefined(); + }); + + it("skips channels with missing payTo and surfaces onError", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + await seed({ payTo: "", firstSeenAt: Date.now() - 7_200_000, expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Open })); + + const onError = vi.fn(); + const onClose = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, onClose, onError }); + expect(onClose).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("missing payTo") }), + expect.objectContaining({ channelId: expect.any(String) }), + ); + }); + + // The close cap must not end the record scan: reclaims are budgeted separately, + // so a backlog of closable channels would otherwise strand rent forever. + it("still reclaims when the close budget is spent", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const closable = await seed({ expiresAt: nowSecs - 200 }); + const distributed = await seed({ expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockImplementation((_rpc: unknown, channelId: string) => { + if (channelId === closable.channelId) { + return Promise.resolve(channelAccount({ status: ChannelStatus.Open })); + } + if (channelId === distributed.channelId) { + return Promise.resolve(channelAccount({ status: ChannelStatus.Distributed })); + } + return Promise.resolve({ exists: false }); + }); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + + const onReclaim = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, maxClosesPerRun: 0, onReclaim }); + + expect(onReclaim).toHaveBeenCalledTimes(1); + expect(onReclaim.mock.calls[0]![0].channelIds).toEqual([distributed.channelId]); + }); + + // A backlog bigger than maxTxsPerRun must eventually reach every record + // instead of only ever reprocessing whatever storage.list() returns first. + it("resumes scanning from the cursor after the budget runs out", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const first = await seed({ expiresAt: nowSecs - 200 }); + const second = await seed({ expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Sealed })); + submitChannelMock.mockResolvedValue("Sig11111111111111111111111111111111111111111"); + + const firstPassCloses = vi.fn(); + await manager.cleanup({ maxTxsPerRun: 1, onClose: firstPassCloses }); + expect(firstPassCloses).toHaveBeenCalledTimes(1); + const closedFirst = firstPassCloses.mock.calls[0]![0].channelId as string; + expect([first.channelId, second.channelId]).toContain(closedFirst); + const cursor = (manager as unknown as { scanCursor: string }).scanCursor; + expect(cursor).not.toBe(closedFirst); + + const secondPassCloses = vi.fn(); + await manager.cleanup({ onClose: secondPassCloses }); + expect(secondPassCloses.mock.calls[0]![0].channelId).toBe(cursor); + expect((manager as unknown as { scanCursor: string }).scanCursor).toBe(""); + }); + + // storage.list() promises no order, so the manager imposes one. Without it + // the resume cursor would mean something different on every storage backend. + it("scans in channel id order regardless of what storage.list returns", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const first = await seed({ expiresAt: nowSecs - 200 }); + const second = await seed({ expiresAt: nowSecs - 200 }); + const lowest = [first.channelId, second.channelId].sort()[0]; + + const descending = (await storage.list()).sort((a, b) => (a.channelId < b.channelId ? 1 : -1)); + vi.spyOn(storage, "list").mockResolvedValue(descending); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Sealed })); + + const onClose = vi.fn(); + await manager.cleanup({ maxTxsPerRun: 1, onClose }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose.mock.calls[0]![0].channelId).toBe(lowest); + }); + + // An unrecognized status has no cleanup path, so the record would sit in storage + // forever without the operator ever hearing about it. + it("reports an unrecognized channel status", async () => { + const record = await seed(); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: 99 as ChannelStatus })); + + const onError = vi.fn(); + const onClose = vi.fn(); + const onReclaim = vi.fn(); + await manager.cleanup({ onClose, onError, onReclaim }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("unrecognized status") }), + expect.objectContaining({ channelId: record.channelId }), + ); + expect(onClose).not.toHaveBeenCalled(); + expect(onReclaim).not.toHaveBeenCalled(); + expect(await storage.get(record.channelId)).toBeDefined(); + }); + + // An operator cron calling cleanup() must not race the interval loop into + // submitting the same close twice. + it("runs overlapping passes serially", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + await seed({ expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Sealed })); + + let inFlight = 0; + let overlapped = false; + submitChannelMock.mockImplementation(async () => { + inFlight += 1; + if (inFlight > 1) overlapped = true; + await new Promise(resolve => setTimeout(resolve, 10)); + inFlight -= 1; + return "Sig11111111111111111111111111111111111111111"; + }); + + await Promise.all([manager.cleanup({}), manager.cleanup({}), manager.cleanup({})]); + + expect(overlapped).toBe(false); + }); + + it("stops a scan in progress at the next record when the caller aborts", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + await seed({ expiresAt: nowSecs - 200 }); + await seed({ expiresAt: nowSecs - 200 }); + await seed({ expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Sealed })); + + const controller = new AbortController(); + submitChannelMock.mockImplementation(async () => { + controller.abort(); + return "Sig11111111111111111111111111111111111111111"; + }); + + await expect(manager.cleanup({ signal: controller.signal })).rejects.toThrow( + expect.objectContaining({ name: "AbortError" }), + ); + // The first record's settle completed; the abort is observed before the + // second one is classified. + expect(submitChannelMock).toHaveBeenCalledTimes(1); + }); + + it("stop waits for the in-flight pass and does not report the abort as an error", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + await seed({ expiresAt: nowSecs - 200 }); + await seed({ expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue(channelAccount({ status: ChannelStatus.Sealed })); + + let settleFinished = false; + let releaseSettle: (() => void) | undefined; + const settleStarted = new Promise(resolve => { + submitChannelMock.mockImplementation(async () => { + resolve(); + await new Promise(release => { + releaseSettle = release; + }); + settleFinished = true; + return "Sig11111111111111111111111111111111111111111"; + }); + }); + + const onError = vi.fn(); + manager.start({ intervalSecs: 0.01, onError }); + await settleStarted; + + const stopped = manager.stop(); + let stopResolved = false; + void stopped.then(() => { + stopResolved = true; + }); + + // stop() must not resolve while a broadcast settle is still outstanding. + await new Promise(resolve => setTimeout(resolve, 20)); + expect(stopResolved).toBe(false); + + releaseSettle?.(); + await stopped; + expect(settleFinished).toBe(true); + expect(onError).not.toHaveBeenCalled(); + }); + + it("stop is idempotent and safe when no pass ever ran", async () => { + await manager.stop(); + await manager.stop(); + expect(submitChannelMock).not.toHaveBeenCalled(); + }); + + it("skips channels whose feePayer is not in the signer set", async () => { + const nowSecs = Math.floor(Date.now() / 1_000); + const other = await generateKeyPairSigner(); + await seed({ firstSeenAt: Date.now() - 7_200_000, expiresAt: nowSecs - 200 }); + fetchMaybeChannelMock.mockResolvedValue( + channelAccount({ + status: ChannelStatus.Open, + payee: other.address, + rentPayer: other.address, + }), + ); + + const onError = vi.fn(); + await manager.cleanup({ abandonGraceSecs: 120, onError }); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("not in facilitator signer set"), + }), + expect.any(Object), + ); + expect(submitChannelMock).not.toHaveBeenCalled(); + }); +}); + +/** + * Build a facilitator signer over more than one key. Channel submission is + * mocked in these tests, so `getSigner` need only return a value tied to + * its `feePayer`, not a real kit signer. + * + * @param signers - Underlying kit signers this facilitator manages + */ +function multiKeySigner( + signers: Awaited>[], +): FacilitatorSvmSigner { + const byAddress = new Map(signers.map(signer => [signer.address, signer])); + return { + getAddresses: () => signers.map(signer => signer.address), + getSigner: (feePayer: Address) => { + const signer = byAddress.get(feePayer); + if (!signer) throw new Error(`no signer for feePayer ${feePayer}`); + return signer as unknown as TransactionSigner & { signMessages: never }; + }, + getAccountInfo: vi.fn(), + getLatestBlockhash: vi.fn(), + getSlot: vi.fn().mockImplementation(() => getSlotMock()), + getProgramAccounts: vi.fn(), + signTransaction: vi.fn(), + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + confirmTransaction: vi.fn(), + } as FacilitatorSvmSigner; +} + +describe("BatchSvmRentCleanupManager — onchain discovery", () => { + let feePayer: Awaited>; + let payer: Awaited>; + let storage: InMemoryPaymentChannelStorage; + let manager: BatchSvmRentCleanupManager; + let discoveredChannelId: Address; + + beforeEach(async () => { + vi.clearAllMocks(); + feePayer = await generateKeyPairSigner(); + payer = await generateKeyPairSigner(); + discoveredChannelId = (await generateKeyPairSigner()).address; + storage = new InMemoryPaymentChannelStorage(); + manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: cleanupSigner(feePayer), + storage, + }); + submitChannelMock.mockResolvedValue("Sig11111111111111111111111111111111111111111"); + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + discoverChannelsMock.mockResolvedValue([]); + }); + + /** + * @param status - Live channel status discovery should report + */ + function discovered(status: ChannelStatus) { + return { + channel: { + mint: USDC_DEVNET_ADDRESS, + openSlot: OPEN_SLOT, + payee: feePayer.address, + payer: payer.address, + rentPayer: feePayer.address, + status, + }, + channelId: discoveredChannelId, + }; + } + + it("adds an untracked Distributed channel to storage for cleanup to reclaim", async () => { + discoverChannelsMock.mockResolvedValue([discovered(ChannelStatus.Distributed)]); + + const onDiscover = vi.fn(); + await manager.discover({ onDiscover }); + + expect(discoverChannelsMock).toHaveBeenCalledWith(expect.anything(), NETWORK, feePayer.address); + expect(onDiscover).toHaveBeenCalledWith({ channelIds: [discoveredChannelId] }); + // Only what the chain proves: the Open/Sealed metadata stays empty, which + // a Distributed channel never needs again. + expect(await storage.get(discoveredChannelId)).toMatchObject({ + channelId: discoveredChannelId, + expiresAt: 0, + network: NETWORK, + payTo: "", + tokenProgram: "", + }); + // Discovery itself never submits: reclaiming is the next cleanup's job. + expect(submitChannelMock).not.toHaveBeenCalled(); + }); + + it("reclaims a discovered channel on the following cleanup pass", async () => { + discoverChannelsMock.mockResolvedValue([discovered(ChannelStatus.Distributed)]); + fetchMaybeChannelMock.mockResolvedValue({ + data: { + mint: USDC_DEVNET_ADDRESS, + openSlot: OPEN_SLOT, + payee: feePayer.address, + payer: payer.address, + rentPayer: feePayer.address, + status: ChannelStatus.Distributed, + }, + exists: true, + }); + + await manager.discover(); + const onReclaim = vi.fn(); + await manager.cleanup({ onReclaim }); + + expect(onReclaim).toHaveBeenCalledWith( + expect.objectContaining({ channelIds: [discoveredChannelId] }), + ); + }); + + it("ignores discovered channels that are not Distributed", async () => { + discoverChannelsMock.mockResolvedValue([discovered(ChannelStatus.Open)]); + + const onDiscover = vi.fn(); + await manager.discover({ onDiscover }); + + expect(onDiscover).not.toHaveBeenCalled(); + expect(await storage.list()).toHaveLength(0); + }); + + it("ignores discovered channels still inside the open-slot window", async () => { + discoverChannelsMock.mockResolvedValue([discovered(ChannelStatus.Distributed)]); + getSlotMock.mockResolvedValue(CURRENT_SLOT_TOO_EARLY); + + await manager.discover(); + + expect(await storage.list()).toHaveLength(0); + }); + + // Discovery only knows what the chain proves, so overwriting a settle-time + // record with a partial one would lose the payTo an abandon-close needs. + it("never overwrites a channel already tracked in storage", async () => { + const tracked: PaymentChannelRecord = { + channelId: discoveredChannelId, + expiresAt: FAR_FUTURE, + firstSeenAt: Date.now(), + network: NETWORK, + payTo: payer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }; + await storage.upsert(tracked); + discoverChannelsMock.mockResolvedValue([discovered(ChannelStatus.Distributed)]); + + const onDiscover = vi.fn(); + await manager.discover({ onDiscover }); + + expect(onDiscover).not.toHaveBeenCalled(); + expect(await storage.get(discoveredChannelId)).toMatchObject({ + payTo: payer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + }); + + it("reports a sweep failure for one signer and continues", async () => { + const other = await generateKeyPairSigner(); + manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: multiKeySigner([feePayer, other]), + storage, + }); + discoverChannelsMock + .mockRejectedValueOnce(new Error("getProgramAccounts failed")) + .mockResolvedValueOnce([discovered(ChannelStatus.Distributed)]); + + const onError = vi.fn(); + const onDiscover = vi.fn(); + await manager.discover({ onDiscover, onError }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "getProgramAccounts failed" }), + ); + expect(onDiscover).toHaveBeenCalledWith({ channelIds: [discoveredChannelId] }); + }); + + it("runs discovery on its own interval, not the cleanup interval", async () => { + discoverChannelsMock.mockResolvedValue([]); + fetchMaybeChannelMock.mockResolvedValue({ exists: false }); + + manager.start({ intervalSecs: 0.01, discoveryIntervalSecs: 10 }); + await new Promise(resolve => setTimeout(resolve, 60)); + await manager.stop(); + + // Several cleanup ticks elapsed; the daily sweep is not due yet. + expect(discoverChannelsMock).not.toHaveBeenCalled(); + }); + + it("does not sweep at all when no discovery interval is configured", async () => { + discoverChannelsMock.mockResolvedValue([]); + fetchMaybeChannelMock.mockResolvedValue({ exists: false }); + + manager.start({ intervalSecs: 0.01 }); + await new Promise(resolve => setTimeout(resolve, 60)); + await manager.stop(); + + expect(discoverChannelsMock).not.toHaveBeenCalled(); + }); +}); + +describe("BatchSvmRentCleanupManager — concurrent signer groups", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("submits independent rent-payer groups concurrently", async () => { + const feePayerA = await generateKeyPairSigner(); + const feePayerB = await generateKeyPairSigner(); + const payer = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + const storage = new InMemoryPaymentChannelStorage(); + const manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: multiKeySigner([feePayerA, feePayerB]), + storage, + }); + + const recordA = (await generateKeyPairSigner()).address; + const recordB = (await generateKeyPairSigner()).address; + await storage.upsert({ + channelId: recordA, + expiresAt: FAR_FUTURE, + firstSeenAt: Date.now(), + network: NETWORK, + payTo: payTo.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + } as PaymentChannelRecord); + await storage.upsert({ + channelId: recordB, + expiresAt: FAR_FUTURE, + firstSeenAt: Date.now(), + network: NETWORK, + payTo: payTo.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + } as PaymentChannelRecord); + + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + fetchMaybeChannelMock.mockImplementation((_rpc: unknown, channelId: string) => { + const rentPayer = channelId === recordA ? feePayerA.address : feePayerB.address; + return Promise.resolve({ + data: { + mint: USDC_DEVNET_ADDRESS, + openSlot: OPEN_SLOT, + payee: rentPayer, + payer: payer.address, + rentPayer, + status: ChannelStatus.Distributed, + }, + exists: true, + }); + }); + + let entered = 0; + let sawConcurrentEntry = false; + const releasers: (() => void)[] = []; + submitChannelMock.mockImplementation( + () => + new Promise(resolve => { + entered += 1; + if (entered > 1) sawConcurrentEntry = true; + releasers.push(() => resolve("Sig11111111111111111111111111111111111111111")); + }), + ); + + const pass = manager.cleanup({}); + // Let both groups reach submitSettle before releasing either. + await vi.waitFor(() => expect(releasers).toHaveLength(2)); + releasers.forEach(release => release()); + await pass; + + expect(sawConcurrentEntry).toBe(true); + expect(submitChannelMock).toHaveBeenCalledTimes(2); + }); + + it("budgets each rent-payer group independently instead of sharing a pool", async () => { + const feePayerA = await generateKeyPairSigner(); + const feePayerB = await generateKeyPairSigner(); + const payer = await generateKeyPairSigner(); + const payTo = await generateKeyPairSigner(); + const storage = new InMemoryPaymentChannelStorage(); + const manager = new BatchSvmRentCleanupManager({ + network: NETWORK, + signer: multiKeySigner([feePayerA, feePayerB]), + storage, + }); + + const channelIds: string[] = []; + for (let group = 0; group < 2; group++) { + for (let i = 0; i < 3; i++) { + const channel = await generateKeyPairSigner(); + await storage.upsert({ + channelId: channel.address, + expiresAt: FAR_FUTURE, + firstSeenAt: Date.now(), + network: NETWORK, + payTo: payTo.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + } as PaymentChannelRecord); + channelIds.push(channel.address); + } + } + const rentPayerFor = new Map([ + [channelIds[0]!, feePayerA.address], + [channelIds[1]!, feePayerA.address], + [channelIds[2]!, feePayerA.address], + [channelIds[3]!, feePayerB.address], + [channelIds[4]!, feePayerB.address], + [channelIds[5]!, feePayerB.address], + ]); + + getSlotMock.mockResolvedValue(CURRENT_SLOT_READY); + fetchMaybeChannelMock.mockImplementation((_rpc: unknown, channelId: string) => { + const rentPayer = rentPayerFor.get(channelId)!; + return Promise.resolve({ + data: { + mint: USDC_DEVNET_ADDRESS, + openSlot: OPEN_SLOT, + payee: rentPayer, + payer: payer.address, + rentPayer, + status: ChannelStatus.Distributed, + }, + exists: true, + }); + }); + submitChannelMock.mockResolvedValue("Sig11111111111111111111111111111111111111111"); + + const onReclaim = vi.fn(); + await manager.cleanup({ maxReclaimsPerTx: 1, maxTxsPerSigner: 2, onReclaim }); + + // Each of the two rent-payer groups gets its own budget of 2, not a + // shared pool of 2 total. + expect(onReclaim).toHaveBeenCalledTimes(4); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.seal.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.seal.test.ts new file mode 100644 index 0000000000..a4df21ca68 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.seal.test.ts @@ -0,0 +1,419 @@ +import { address, generateKeyPairSigner, type Signature } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { signCloseAuthorization } from "../../src/batch-settlement/closeAuthorization"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { BatchSvmScheme } from "../../src/batch-settlement/facilitator/scheme"; +import { encodeReceiverBindingMemo } from "../../src/batch-settlement/receiverBinding"; +import { + isBatchFacilitatorPayload, + type BatchChannelConfig, + type BatchSealPayload, +} from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import { getChannelDistributionHash } from "../../src/payment-channels/facilitator"; +import { ChannelStatus } from "../../src/payment-channels/onchain"; +import { buildOpenPaymentChannelTransaction } from "../../src/payment-channels/open"; +import { signVoucher } from "../../src/payment-channels/voucher"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const SIGNATURE = USDC_DEVNET_ADDRESS as Signature; +const NOW = 1_800_000_000; + +let payer: Awaited>; +let feePayer: Awaited>; +let authorizer: Awaited>; +let channelId: string; +let channelConfig: BatchChannelConfig; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + authorizer = await generateKeyPairSigner(); + const open = await buildOpenPaymentChannelTransaction({ + authorizedSigner: payer.address, + bindingMemo: encodeReceiverBindingMemo(authorizer.address), + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 0n }, + deposit: 10_000n, + feePayer: feePayer.address, + gracePeriod: 900, + mint: MINT, + openSlot: 1n, + payee: feePayer.address, + payer, + recipients: [{ bps: 10_000, recipient: RECEIVER }], + salt: 0n, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + }); + channelId = open.channelId; + channelConfig = { + openSlot: 1, + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + receiverAuthorizer: authorizer.address, + salt: "0", + token: MINT, + withdrawDelay: 900, + }; +}); + +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: authorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function channel(overrides: Partial = {}): Channel { + return { + authorizedSigner: address(payer.address), + bump: 1, + closureStartedAt: BigInt(NOW - 60), + deposit: 10_000n, + discriminator: 1, + distributionHash: getChannelDistributionHash([{ bps: 10_000, recipient: RECEIVER }]), + gracePeriod: 900, + mint: address(MINT), + openSlot: 1n, + payee: address(feePayer.address), + payer: address(payer.address), + payerWithdrawnAt: 0n, + rentPayer: address(feePayer.address), + salt: 0n, + settlement: { payoutWatermark: 500n, settled: 1_000n }, + status: ChannelStatus.Closing, + version: 1, + ...overrides, + }; +} + +function signer() { + return { + confirmTransaction: vi.fn().mockResolvedValue(undefined), + getAccountInfo: vi.fn().mockResolvedValue({ owner: TOKEN_PROGRAM_ADDRESS }), + getAddresses: vi.fn(() => [feePayer.address]), + getSigner: vi.fn(() => feePayer), + sendTransaction: vi.fn().mockResolvedValue(SIGNATURE), + signTransaction: vi.fn().mockResolvedValue("signed"), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + }; +} + +type Internals = { + resolveTerms: ReturnType; + deriveChannelId: ReturnType; + fetchChannel: ReturnType; + readChannel: ReturnType; + distributeInstruction: ReturnType; + submitRedemption: ReturnType; + trackChannel: ReturnType; + sealDependencies(): { nowSeconds(): number }; +}; + +async function sealPayload(cumulative: bigint, overrides: Partial = {}) { + const signature = await signVoucher(payer, { + channelId, + cumulativeAmount: cumulative, + expiresAt: 0n, + }); + const closeAuthorization = await signCloseAuthorization(authorizer, { + channelId, + feePayer: feePayer.address, + maxClaimableAmount: cumulative, + network: NETWORK, + validBefore: NOW + 120, + voucherExpiresAt: 0n, + }); + const payload: BatchSealPayload = { + channelConfig, + channelId, + closeAuthorization, + type: "seal", + voucher: { channelId, expiresAt: 0, maxClaimableAmount: cumulative.toString(), signature }, + ...overrides, + }; + return payload; +} + +/** + * Facilitator whose chain reads are stubbed and whose clock is pinned. + * + * @param options - Binding seed and live channel + * @param options.live - The channel account the facilitator reads + * @param options.bound - Whether the receiver authorizer was bound at deposit + * @returns The scheme, its stubbed internals, and its binding store + */ +async function facilitator( + options: { + live?: Channel; + bound?: boolean; + } = {}, +) { + const store = new InMemoryBatchReceiverAuthorizerStore(); + if (options.bound ?? true) { + await store.bind({ channelId, network: NETWORK, receiverAuthorizer: authorizer.address }); + } + const scheme = new BatchSvmScheme(signer() as never, { + receiverAuthorizerStore: store, + }); + const api = scheme as unknown as Internals; + api.resolveTerms = vi.fn().mockResolvedValue({ + feePayer: feePayer.address, + feePayerSigner: feePayer, + receiverAuthorizer: authorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "client", + withdrawDelay: 900, + }); + api.deriveChannelId = vi.fn().mockResolvedValue(channelId); + api.fetchChannel = vi.fn().mockResolvedValue(options.live ?? channel()); + api.readChannel = vi.fn().mockResolvedValue(undefined); + api.distributeInstruction = vi.fn().mockResolvedValue({ + accounts: [], + data: new Uint8Array([9]), + programAddress: address(RECEIVER), + }); + api.submitRedemption = vi.fn().mockResolvedValue({ + ok: true, + replayed: false, + signature: SIGNATURE, + }); + api.trackChannel = vi.fn().mockResolvedValue(undefined); + const original = api.sealDependencies.bind(scheme); + api.sealDependencies = () => ({ ...original(), nowSeconds: () => NOW }); + return { api, scheme, store }; +} + +const settle = (scheme: BatchSvmScheme, payload: BatchSealPayload) => + scheme.settle({ accepted: requirements(), payload, x402Version: 2 } as never, requirements()); + +describe("batch-settlement seal", () => { + it("accepts only a well-formed seal payload", async () => { + const payload = await sealPayload(3_000n); + expect(isBatchFacilitatorPayload(payload)).toBe(true); + expect(isBatchFacilitatorPayload({ ...payload, closeAuthorization: undefined })).toBe(true); + expect(isBatchFacilitatorPayload({ ...payload, voucher: undefined })).toBe(false); + expect( + isBatchFacilitatorPayload({ + ...payload, + closeAuthorization: { signature: "x", validBefore: 0 }, + }), + ).toBe(false); + }); + + it("applies the final voucher with settle_and_seal and a sealed distribute in one transaction", async () => { + const { api, scheme } = await facilitator(); + const response = await settle(scheme, await sealPayload(3_000n)); + expect(response).toMatchObject({ + // 3000 settled less the 500 already paid out reaches the receiver now. + amount: "2500", + extra: { + channelState: { + balance: "10000", + channelId, + totalClaimed: "3000", + withdrawRequestedAt: 0, + }, + }, + payer: payer.address, + success: true, + transaction: SIGNATURE, + }); + const [feePayerArg, network, instructions, key] = api.submitRedemption.mock.calls[0]!; + expect(feePayerArg).toBe(feePayer.address); + expect(network).toBe(NETWORK); + // Ed25519 precompile, settle_and_seal, distribute. + expect(instructions).toHaveLength(3); + expect(key).toBe(`batch:seal:${NETWORK}:${channelId}:3000`); + expect(api.trackChannel).toHaveBeenCalledOnce(); + + // The same close replays from the recorded result without a second broadcast. + await expect(settle(scheme, await sealPayload(3_000n))).resolves.toMatchObject({ + amount: "2500", + success: true, + }); + expect(api.submitRedemption).toHaveBeenCalledOnce(); + }); + + it("seals at the current watermark without a precompile when the voucher equals settled", async () => { + const { api, scheme } = await facilitator(); + const response = await settle(scheme, await sealPayload(1_000n)); + expect(response).toMatchObject({ amount: "500", success: true }); + expect(api.submitRedemption.mock.calls[0]![2]).toHaveLength(2); + }); + + it("refuses channels that are not closing or whose grace period has elapsed", async () => { + const open = await facilitator({ + live: channel({ closureStartedAt: 0n, status: ChannelStatus.Open }), + }); + await expect(settle(open.scheme, await sealPayload(3_000n))).resolves.toMatchObject({ + errorReason: BatchError.CLOSE_STATE, + success: false, + }); + const late = await facilitator({ live: channel({ closureStartedAt: BigInt(NOW - 900) }) }); + await expect(settle(late.scheme, await sealPayload(3_000n))).resolves.toMatchObject({ + errorReason: BatchError.CLOSE_STATE, + success: false, + }); + const behind = await facilitator(); + await expect(settle(behind.scheme, await sealPayload(999n))).resolves.toMatchObject({ + errorReason: BatchError.CLOSE_STATE, + success: false, + }); + const above = await facilitator(); + await expect(settle(above.scheme, await sealPayload(10_001n))).resolves.toMatchObject({ + errorReason: BatchError.CLOSE_STATE, + success: false, + }); + expect(open.api.submitRedemption).not.toHaveBeenCalled(); + }); + + it("authenticates the server through the receiver authorizer bound at deposit", async () => { + const payload = await sealPayload(3_000n); + const missing = await facilitator(); + await expect( + missing.scheme.settle( + { + accepted: requirements(), + payload: { ...payload, closeAuthorization: undefined }, + x402Version: 2, + }, + requirements(), + ), + ).resolves.toMatchObject({ errorReason: BatchError.CLOSE_AUTHORIZATION, success: false }); + + const forged = await facilitator(); + const impostor = await generateKeyPairSigner(); + const forgedAuthorization = await signCloseAuthorization(impostor, { + channelId, + feePayer: feePayer.address, + maxClaimableAmount: 3_000n, + network: NETWORK, + validBefore: NOW + 120, + voucherExpiresAt: 0n, + }); + await expect( + settle(forged.scheme, { ...payload, closeAuthorization: forgedAuthorization }), + ).resolves.toMatchObject({ errorReason: BatchError.CLOSE_AUTHORIZATION, success: false }); + + // A payer replaying an older voucher cannot freeze the watermark low: the + // authorization binds the exact final amount. + const stale = await facilitator(); + const older = await sealPayload(2_000n); + await expect( + settle(stale.scheme, { ...older, closeAuthorization: payload.closeAuthorization }), + ).resolves.toMatchObject({ errorReason: BatchError.CLOSE_AUTHORIZATION, success: false }); + + // No stored binding: fail closed. + const unbound = await facilitator({ bound: false }); + await expect(settle(unbound.scheme, payload)).resolves.toMatchObject({ + errorReason: BatchError.RECEIVER_BINDING_UNAVAILABLE, + success: false, + }); + + // A key other than the binding cannot authorize, even when validly signed. + const rebound = await facilitator({ bound: false }); + await rebound.store.bind({ channelId, network: NETWORK, receiverAuthorizer: payer.address }); + await expect(settle(rebound.scheme, payload)).resolves.toMatchObject({ + errorReason: BatchError.RECEIVER_AUTHORIZER_MISMATCH, + success: false, + }); + expect(missing.api.submitRedemption).not.toHaveBeenCalled(); + expect(forged.api.submitRedemption).not.toHaveBeenCalled(); + expect(stale.api.submitRedemption).not.toHaveBeenCalled(); + expect(unbound.api.submitRedemption).not.toHaveBeenCalled(); + }); + + it("refunds an open channel cooperatively with the server-authorized voucher", async () => { + const open = channel({ closureStartedAt: 0n, status: ChannelStatus.Open }); + const refund = async (cumulative: bigint) => { + const { closeAuthorization, voucher } = await sealPayload(cumulative); + const { api, scheme } = await facilitator({ live: open }); + const response = await scheme.settle( + { + accepted: requirements(), + payload: { channelConfig, closeAuthorization, type: "refund", voucher }, + x402Version: 2, + } as never, + requirements(), + ); + return { api, response }; + }; + + const above = await refund(3_000n); + expect(above.response).toMatchObject({ + amount: "7000", + extra: { channelState: { channelId, withdrawRequestedAt: 0 } }, + success: true, + transaction: SIGNATURE, + }); + const [, , instructions, key] = above.api.submitRedemption.mock.calls[0]!; + expect(instructions).toHaveLength(3); + expect(key).toBe(`batch:refund:${NETWORK}:${channelId}:3000`); + + // At the watermark the channel seals without a voucher precompile. + const equal = await refund(1_000n); + expect(equal.response).toMatchObject({ amount: "9000", success: true }); + expect(equal.api.submitRedemption.mock.calls[0]![2]).toHaveLength(2); + + // A closing channel cannot be refunded cooperatively through the seal path. + const { closeAuthorization, voucher } = await sealPayload(3_000n); + const closing = await facilitator(); + closing.api.readChannel = vi.fn().mockResolvedValue(undefined); + closing.api.fetchChannel = vi.fn().mockResolvedValue(channel()); + await expect( + closing.scheme.settle( + { + accepted: requirements(), + payload: { channelConfig, closeAuthorization, type: "refund", voucher }, + x402Version: 2, + } as never, + requirements(), + ), + ).resolves.toMatchObject({ errorReason: BatchError.CLOSE_STATE, success: false }); + }); + + it("rejects a claim against a closing channel with the dedicated code", async () => { + const { scheme } = await facilitator(); + const signature = await signVoucher(payer, { + channelId, + cumulativeAmount: 3_000n, + expiresAt: 0n, + }); + const response = await scheme.settle( + { + accepted: requirements(), + payload: { + claims: [ + { + channelConfig, + channelId, + voucher: { channelId, expiresAt: 0, maxClaimableAmount: "3000", signature }, + }, + ], + type: "claim", + }, + x402Version: 2, + } as never, + requirements(), + ); + expect(response).toMatchObject({ errorReason: BatchError.CHANNEL_CLOSING, success: false }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.server-signer.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.server-signer.test.ts new file mode 100644 index 0000000000..677374699a --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.server-signer.test.ts @@ -0,0 +1,735 @@ +import { generateKeyPairSigner, getBase58Decoder } from "@solana/kit"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + encodeBatchAuthorizationMessage, + signBatchAuthorization, + verifyBatchAuthorization, +} from "../../src/batch-settlement/authorization"; +import { + BatchChannelTracker, + buildDepositPayload, + buildRefundPayload, + signBatchVoucher, +} from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { BatchSvmScheme as BatchServerScheme } from "../../src/batch-settlement/server/scheme"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import { MemoryBatchOperationStore } from "../../src/batch-settlement/server/operationStore"; +import { + isBatchChannelConfig, + isBatchFacilitatorPayload, + isBatchPayload, + isBatchVoucher, + type BatchChannelConfig, + type BatchDepositPayload, + type BatchVoucher, +} from "../../src/batch-settlement/types"; +import { TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { + encodeVoucherMessageBytes, + verifyVoucherSignature, +} from "../../src/payment-channels/voucher"; + +let payer: Awaited>; +let operator: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; +let serverDeposit: BatchDepositPayload; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + operator = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + serverDeposit = ( + await buildDepositPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: USDC_DEVNET_ADDRESS, + openSlot: 123n, + operator: operator.address, + payer, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: 900, + authorizationExpiresAt: Math.floor(Date.now() / 1000) + 3_600, + }) + ).payload; +}); + +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: feePayer.address, + operator: operator.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: USDC_MAINNET_ADDRESS, + scheme: "batch-settlement", + }; +} + +async function authorizationFor(requestId: string, authorizedAmount = 1_000n) { + return signBatchAuthorization( + payer, + serverDeposit.authorization!.channelId, + operator.address, + requestId, + authorizedAmount, + Math.floor(Date.now() / 1000) + 3_600, + ); +} + +describe("batch server voucher signer boundaries", () => { + it("covers malformed top-level wire guards", () => { + expect(isBatchVoucher(null)).toBe(false); + expect(isBatchFacilitatorPayload(null)).toBe(false); + expect(isBatchChannelConfig({ ...serverDeposit.channelConfig, receiverAuthorizer: 1 })).toBe( + false, + ); + }); + + it("rejects malformed server and client wire combinations", async () => { + expect(isBatchPayload(serverDeposit)).toBe(true); + const clientDeposit = ( + await buildDepositPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: USDC_DEVNET_ADDRESS, + openSlot: 123n, + payer, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }) + ).payload; + expect(isBatchPayload(clientDeposit)).toBe(true); + + const invalid = [ + { ...serverDeposit, deposit: null }, + { ...serverDeposit, deposit: { ...serverDeposit.deposit, amount: 1 } }, + { ...serverDeposit, deposit: { ...serverDeposit.deposit, transaction: 1 } }, + { ...serverDeposit, voucher: clientDeposit.voucher }, + { ...serverDeposit, authorization: null }, + { ...serverDeposit, authorization: { ...serverDeposit.authorization, type: "other" } }, + { ...serverDeposit, authorization: { ...serverDeposit.authorization, channelId: 1 } }, + { ...serverDeposit, authorization: { ...serverDeposit.authorization, payer: 1 } }, + { ...serverDeposit, authorization: { ...serverDeposit.authorization, expiresAt: 0 } }, + { ...serverDeposit, authorization: { ...serverDeposit.authorization, signature: 1 } }, + { ...serverDeposit, requestId: "" }, + { ...serverDeposit, maxClaimableAmount: 1 }, + { ...clientDeposit, authorization: serverDeposit.authorization }, + { ...clientDeposit, requestId: "key" }, + { ...clientDeposit, maxClaimableAmount: "1000" }, + { + channelConfig: serverDeposit.channelConfig, + type: "voucher", + voucher: clientDeposit.voucher, + }, + { + authorization: serverDeposit.authorization, + channelConfig: clientDeposit.channelConfig, + requestId: "key", + maxClaimableAmount: "1000", + type: "authorization", + }, + { + authorization: null, + channelConfig: serverDeposit.channelConfig, + requestId: "key", + maxClaimableAmount: "1000", + type: "authorization", + }, + { + authorization: serverDeposit.authorization, + channelConfig: serverDeposit.channelConfig, + requestId: "", + maxClaimableAmount: "1000", + type: "authorization", + }, + { + authorization: serverDeposit.authorization, + channelConfig: serverDeposit.channelConfig, + requestId: "key", + maxClaimableAmount: 1, + type: "authorization", + }, + ]; + for (const payload of invalid) expect(isBatchPayload(payload)).toBe(false); + + expect(isBatchChannelConfig({ ...serverDeposit.channelConfig, voucherSigner: "unknown" })).toBe( + false, + ); + expect(isBatchChannelConfig({ ...serverDeposit.channelConfig, voucherSigner: "client" })).toBe( + true, + ); + }); + + it("prevents using the wrong credential API for either signer mode", async () => { + const serverTracker = new BatchChannelTracker( + serverDeposit.authorization!.channelId, + serverDeposit.channelConfig, + payer, + ); + await expect(serverTracker.previewVoucher(1n)).rejects.toThrow(/do not use client vouchers/); + + const clientConfig: BatchChannelConfig = { + ...serverDeposit.channelConfig, + payerAuthorizer: payer.address, + voucherSigner: "client", + }; + const clientTracker = new BatchChannelTracker( + serverDeposit.authorization!.channelId, + clientConfig, + payer, + ); + await expect( + clientTracker.authorization("request", 1n, Math.floor(Date.now() / 1000) + 60), + ).rejects.toThrow(/do not use server authorization/); + await expect( + buildDepositPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: USDC_DEVNET_ADDRESS, + openSlot: 123n, + payer, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: 900, + }), + ).rejects.toThrow(/operator is required/); + }); + + it("enforces tracker and deposit-builder boundaries in both modes", async () => { + const clientConfig: BatchChannelConfig = { + ...serverDeposit.channelConfig, + payerAuthorizer: payer.address, + voucherSigner: "client", + }; + const tracker = new BatchChannelTracker( + serverDeposit.authorization!.channelId, + clientConfig, + payer, + 2n, + ); + await expect(tracker.previewVoucher(0n)).rejects.toThrow(/positive/); + expect(() => tracker.commit(1n)).toThrow(/backwards/); + await expect( + signBatchVoucher({ address: payer.address, signMessages: async () => [{}] } as never, { + channelId: serverDeposit.authorization!.channelId, + expiresAt: 0, + maxClaimableAmount: 3n, + }), + ).rejects.toThrow(/did not return/); + + const base = { + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: USDC_DEVNET_ADDRESS, + openSlot: 123n, + payer, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }; + await expect(buildDepositPayload({ ...base, firstCharge: 0n })).rejects.toThrow(/positive/); + await expect(buildDepositPayload({ ...base, firstCharge: 10_001n })).rejects.toThrow( + /positive/, + ); + await expect( + buildDepositPayload({ ...base, openSlot: BigInt(Number.MAX_SAFE_INTEGER) + 1n }), + ).rejects.toThrow(/safe integer/); + await expect( + buildDepositPayload({ ...base, receiverAuthorizer: operator.address }), + ).resolves.toMatchObject({ + payload: { channelConfig: { receiverAuthorizer: operator.address } }, + }); + }); + + it("rejects malformed keys and a signer that returns no proof", async () => { + const shortKey = getBase58Decoder().decode(new Uint8Array(31)); + expect(() => + encodeBatchAuthorizationMessage({ + channelId: shortKey, + requestId: "request", + authorizedAmount: 1_000n, + expiresAt: Math.floor(Date.now() / 1000) + 60, + operator: operator.address, + payer: payer.address, + }), + ).toThrow(/decode to 32 bytes/); + await expect( + signBatchAuthorization( + { address: payer.address, signMessages: async () => [{}] } as never, + serverDeposit.authorization!.channelId, + operator.address, + "request", + 1_000n, + Math.floor(Date.now() / 1000) + 60, + ), + ).rejects.toThrow(/did not return/); + }); + + it("binds authorization to both the channel and operator", async () => { + const authorization = serverDeposit.authorization!; + await expect(verifyBatchAuthorization(authorization, operator.address)).resolves.toBe(true); + await expect(verifyBatchAuthorization(authorization, feePayer.address)).resolves.toBe(false); + await expect( + verifyBatchAuthorization({ ...authorization, expiresAt: 1 }, operator.address), + ).resolves.toBe(false); + await expect( + verifyBatchAuthorization({ ...authorization, channelId: feePayer.address }, operator.address), + ).resolves.toBe(false); + }); + + it("rejects every server-mode term and proof mismatch", async () => { + type Internals = { + signOperatorVoucher(channelId: string, cumulative: bigint): Promise; + validatePayload(raw: BatchDepositPayload, requirements: PaymentRequirements): Promise; + validateRequestProof( + raw: BatchDepositPayload, + channelId: string, + mode: "client" | "server", + authorizedAmount: string, + ): Promise; + }; + const api = new BatchServerScheme({ receiverAuthorizer, operator }) as unknown as Internals; + const channelId = serverDeposit.authorization!.channelId; + const req = requirements(); + const withExtra = (extra: Record) => ({ + ...req, + extra: { ...req.extra, ...extra }, + }); + + await expect( + api.validatePayload(serverDeposit, withExtra({ voucherSigner: "other" })), + ).rejects.toThrow(BatchError.CHANNEL_STATE); + await expect( + api.validatePayload( + { + ...serverDeposit, + channelConfig: { ...serverDeposit.channelConfig, voucherSigner: "client" }, + }, + req, + ), + ).rejects.toThrow(BatchError.CHANNEL_STATE); + await expect( + api.validatePayload(serverDeposit, withExtra({ operator: feePayer.address })), + ).rejects.toThrow(BatchError.CHANNEL_STATE); + await expect( + api.validatePayload( + { + ...serverDeposit, + channelConfig: { ...serverDeposit.channelConfig, voucherSigner: undefined }, + }, + withExtra({ operator: operator.address, voucherSigner: "client" }), + ), + ).rejects.toThrow(BatchError.CHANNEL_STATE); + + await expect( + api.validateRequestProof( + { ...serverDeposit, authorization: undefined } as never, + channelId, + "server", + "1000", + ), + ).rejects.toThrow(BatchError.VOUCHER_SIGNATURE); + await expect( + api.validateRequestProof( + { ...serverDeposit, voucher: undefined } as never, + channelId, + "client", + "1000", + ), + ).rejects.toThrow(BatchError.VOUCHER_SIGNATURE); + + const authorizationCases = [ + { authorization: { ...serverDeposit.authorization!, channelId: feePayer.address } }, + { authorization: { ...serverDeposit.authorization!, payer: feePayer.address } }, + { authorization: { ...serverDeposit.authorization!, requestId: "" } }, + { authorization: { ...serverDeposit.authorization!, authorizedAmount: "999" } }, + { authorization: { ...serverDeposit.authorization!, signature: "bad" } }, + ]; + for (const overrides of authorizationCases) { + await expect( + api.validateRequestProof( + { ...serverDeposit, ...overrides } as never, + channelId, + "server", + "1000", + ), + ).rejects.toThrow(BatchError.VOUCHER_SIGNATURE); + } + + const unsigned = new BatchServerScheme({ receiverAuthorizer }) as unknown as Internals; + await expect(unsigned.signOperatorVoucher(channelId, 1n)).rejects.toThrow( + BatchError.VOUCHER_SIGNATURE, + ); + }); + + it("opens and advances a server-signed channel through the server hook lifecycle", async () => { + const store = new MemoryChannelStore(); + const operationStore = new MemoryBatchOperationStore(); + const server = new BatchServerScheme({ + receiverAuthorizer, + operator, + operationStore, + store, + }); + const depositPayment: PaymentPayload = { + accepted: requirements(), + payload: serverDeposit, + x402Version: 2, + }; + const depositContext = { + declaredExtensions: {}, + paymentPayload: depositPayment, + requirements: requirements(), + }; + const verified = await server.schemeHooks.onBeforeVerify!(depositContext); + expect(verified).toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + ...depositContext, + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toBeUndefined(); + await server.schemeHooks.onAfterSettle!({ + ...depositContext, + phase: "after-handler", + result: { + extra: { channelState: { balance: "10000", totalClaimed: "0", withdrawRequestedAt: 0 } }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "open-signature", + }, + }); + const channelId = serverDeposit.authorization!.channelId; + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 1_000n, + signedMaxClaimable: 1_000n, + }); + + const authorizationPayment: PaymentPayload = { + accepted: requirements(), + payload: { + authorization: await authorizationFor("request-2"), + channelConfig: serverDeposit.channelConfig, + type: "authorization", + }, + x402Version: 2, + }; + const authorizationContext = { + declaredExtensions: {}, + paymentPayload: authorizationPayment, + requirements: requirements(), + }; + const authorizationVerified = await server.schemeHooks.onBeforeVerify!(authorizationContext); + expect(authorizationVerified).toMatchObject({ skip: true, result: { isValid: true } }); + await server.schemeHooks.onAfterVerify!({ + ...authorizationContext, + result: (authorizationVerified as { result: { isValid: true; payer: string } }).result, + }); + const actualSettlement = await server.schemeHooks.onBeforeSettle!({ + ...authorizationContext, + requirements: { ...authorizationContext.requirements, amount: "400" }, + phase: "after-handler", + }); + expect(actualSettlement).toMatchObject({ + skip: true, + result: { + extra: { chargedAmount: "400", commitmentId: `${channelId}:1400` }, + success: true, + }, + }); + const receipt = (actualSettlement as { result: { extra: { voucher: BatchVoucher } } }).result + .extra.voucher; + expect( + await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: 1_400n, + expiresAt: 0n, + }), + signatureBase58: receipt.signature, + signerBase58: operator.address, + }), + ).toBe(true); + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 1_400n, + signedMaxClaimable: 1_400n, + }); + await expect(operationStore.get(channelId, "request-2")).resolves.toMatchObject({ + actual: 400n, + cumulative: 1_400n, + status: "completed", + }); + + const zeroPayment = { + ...authorizationPayment, + payload: { + ...authorizationPayment.payload, + authorization: await authorizationFor("request-3"), + }, + } as PaymentPayload; + const zeroContext = { ...authorizationContext, paymentPayload: zeroPayment }; + const zeroVerified = await server.schemeHooks.onBeforeVerify!(zeroContext); + await server.schemeHooks.onAfterVerify!({ + ...zeroContext, + result: (zeroVerified as { result: { isValid: true; payer: string } }).result, + }); + await expect( + server.schemeHooks.onBeforeSettle!({ + ...zeroContext, + requirements: { ...zeroContext.requirements, amount: "0" }, + phase: "after-handler", + }), + ).resolves.toMatchObject({ + result: { + extra: { commitmentId: `${channelId}:1400` }, + success: true, + }, + }); + expect(await store.get(channelId)).toMatchObject({ chargedCumulativeAmount: 1_400n }); + await expect(operationStore.get(channelId, "request-3")).resolves.toMatchObject({ + actual: 0n, + cumulative: 1_400n, + status: "completed", + }); + + const replayPayment = { + ...authorizationPayment, + payload: { ...authorizationPayment.payload }, + } as PaymentPayload; + const replayContext = { ...authorizationContext, paymentPayload: replayPayment }; + await expect(server.schemeHooks.onBeforeVerify!(replayContext)).resolves.toMatchObject({ + skip: true, + }); + const replayVerified = await server.schemeHooks.onAfterVerify!({ + ...replayContext, + result: { isValid: true, payer: payer.address }, + }); + expect(replayVerified).toMatchObject({ abort: true, reason: "duplicate_settlement" }); + }); + + it("reserves concurrent ceilings, completes out of order, and rejects reused ids", async () => { + const store = new MemoryChannelStore(); + const operationStore = new MemoryBatchOperationStore(); + const server = new BatchServerScheme({ receiverAuthorizer, operator, operationStore, store }); + const openPayment: PaymentPayload = { + accepted: requirements(), + payload: serverDeposit, + x402Version: 2, + }; + const openContext = { + declaredExtensions: {}, + paymentPayload: openPayment, + requirements: requirements(), + }; + const openVerified = await server.schemeHooks.onBeforeVerify!(openContext); + expect(openVerified).toBeUndefined(); + await server.schemeHooks.onAfterVerify!({ + ...openContext, + result: { isValid: true, payer: payer.address }, + }); + await server.schemeHooks.onAfterSettle!({ + ...openContext, + phase: "after-handler", + result: { + extra: { channelState: { balance: "10000", totalClaimed: "0", withdrawRequestedAt: 0 } }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "open-signature", + }, + }); + const channelId = serverDeposit.authorization!.channelId; + const ceiling = { ...requirements(), amount: "4000" }; + const payment = async (requestId: string, accepted = ceiling): Promise => ({ + accepted, + payload: { + authorization: await authorizationFor(requestId, BigInt(accepted.amount)), + channelConfig: serverDeposit.channelConfig, + type: "authorization", + }, + x402Version: 2, + }); + const reserve = async (value: PaymentPayload, accepted = ceiling) => { + const context = { declaredExtensions: {}, paymentPayload: value, requirements: accepted }; + const verified = await server.schemeHooks.onBeforeVerify!(context); + expect(verified).toMatchObject({ skip: true }); + const result = await server.schemeHooks.onAfterVerify!({ + ...context, + result: (verified as { result: { isValid: true; payer: string } }).result, + }); + return { context, result }; + }; + + const first = await reserve(await payment("concurrent-1")); + const second = await reserve(await payment("concurrent-2")); + expect(Object.values((await store.get(channelId))?.reservations ?? {})).toHaveLength(2); + + const exhaustedRequirements = { ...requirements(), amount: "2000" }; + const exhaustedPayment = await payment("concurrent-3", exhaustedRequirements); + const exhausted = await reserve(exhaustedPayment, exhaustedRequirements); + expect(exhausted.result).toMatchObject({ + abort: true, + reason: BatchError.CUMULATIVE_EXCEEDS_DEPOSIT, + }); + + const refundPayment: PaymentPayload = { + accepted: requirements(), + payload: await buildRefundPayload({ + authorization: await authorizationFor("refund-close", 0n), + channelConfig: serverDeposit.channelConfig, + channelId, + feePayer: feePayer.address, + payer, + }), + x402Version: 2, + }; + const refundContext = { + declaredExtensions: {}, + paymentPayload: refundPayment, + requirements: requirements(), + }; + const refundVerified = await server.schemeHooks.onBeforeVerify!(refundContext); + expect(refundVerified).toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + ...refundContext, + result: { + extra: { + balance: "10000", + channelId, + totalClaimed: "0", + withdrawRequestedAt: 0, + }, + isValid: true, + payer: payer.address, + }, + }), + ).resolves.toMatchObject({ abort: true, reason: "duplicate_settlement" }); + + await server.schemeHooks.onVerifiedPaymentCanceled!({ + ...first.context, + reason: "handler_error", + settledPhases: [], + }); + const replacementPayment = await payment("concurrent-4", exhaustedRequirements); + const third = await reserve(replacementPayment, exhaustedRequirements); + expect(third.result).toBeUndefined(); + + const thirdSettled = await server.schemeHooks.onBeforeSettle!({ + ...third.context, + requirements: { ...exhaustedRequirements, amount: "500" }, + phase: "after-handler", + }); + expect(thirdSettled).toMatchObject({ + result: { + extra: { + voucher: { maxClaimableAmount: "1500" }, + }, + }, + }); + const secondSettled = await server.schemeHooks.onBeforeSettle!({ + ...second.context, + requirements: { ...ceiling, amount: "250" }, + phase: "after-handler", + }); + expect(secondSettled).toMatchObject({ + result: { + extra: { + voucher: { maxClaimableAmount: "1750" }, + }, + }, + }); + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 1_750n, + reservations: {}, + }); + + const replay = await reserve(replacementPayment, exhaustedRequirements); + expect(replay.result).toMatchObject({ abort: true, reason: "duplicate_settlement" }); + }); + + it("enriches server-mode refunds with the stored operator voucher", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ operator, receiverAuthorizer, store }); + const channelId = serverDeposit.authorization!.channelId; + await store.put({ + channelConfig: serverDeposit.channelConfig, + channelId, + chargedCumulativeAmount: 2_000n, + deposit: 10_000n, + feePayer: feePayer.address, + highestVoucherExpiresAt: 0, + highestVoucherSignature: "stored-operator-sig", + mint: USDC_DEVNET_ADDRESS, + onchainSyncedAt: Date.now(), + openSlot: BigInt(serverDeposit.channelConfig.openSlot), + payer: payer.address, + payerAuthorizer: operator.address, + payoutWatermark: 0n, + receiver: serverDeposit.channelConfig.receiver, + reservations: {}, + salt: BigInt(serverDeposit.channelConfig.salt), + settled: 0n, + signedMaxClaimable: 2_000n, + status: "open", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: serverDeposit.channelConfig.withdrawDelay, + }); + const refund = await buildRefundPayload({ + authorization: await authorizationFor("refund-enrich", 0n), + channelConfig: serverDeposit.channelConfig, + channelId, + feePayer: feePayer.address, + payer, + }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: refund, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + await server.schemeHooks.onBeforeVerify!(ctx); + await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }); + const enriched = await server.enrichSettlementPayload(ctx as never); + expect(enriched).toMatchObject({ + closeAuthorization: expect.anything(), + voucher: { + channelId, + maxClaimableAmount: "2000", + signature: "stored-operator-sig", + }, + }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.server.delegated.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.server.delegated.test.ts new file mode 100644 index 0000000000..88debddc2d --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.server.delegated.test.ts @@ -0,0 +1,88 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { describe, expect, it } from "vitest"; + +import { BatchSvmScheme } from "../../src/batch-settlement/server/scheme"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const NETWORK = SOLANA_DEVNET_CAIP2; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; + +function requirements(): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + maxTimeoutSeconds: 300, + network: NETWORK, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function supported(extra: Record) { + return { extra, network: NETWORK, scheme: "batch-settlement", x402Version: 2 }; +} + +describe("batch-settlement server delegated receiver authorizer", () => { + it("fails startup without a local signer or an advertised key", async () => { + const facilitatorKey = await generateKeyPairSigner(); + const delegated = new BatchSvmScheme({}); + expect( + delegated.validateFacilitatorSupport?.( + NETWORK, + supported({ feePayer: facilitatorKey.address }), + [], + ), + ).toMatch(/receiverAuthorizer/); + expect( + delegated.validateFacilitatorSupport?.( + NETWORK, + supported({ + feePayer: facilitatorKey.address, + receiverAuthorizer: facilitatorKey.address, + }), + [], + ), + ).toBeUndefined(); + + await expect( + delegated.enhancePaymentRequirements( + requirements(), + supported({ + feePayer: facilitatorKey.address, + receiverAuthorizer: facilitatorKey.address, + }), + [], + ), + ).resolves.toMatchObject({ + extra: { receiverAuthorizer: facilitatorKey.address }, + }); + expect(() => + delegated.enhancePaymentRequirements( + requirements(), + supported({ feePayer: facilitatorKey.address }), + [], + ), + ).toThrow(/valid extra.receiverAuthorizer/); + }); + + it("prefers the local signer when the facilitator advertises a different key", async () => { + const local = await generateKeyPairSigner(); + const other = await generateKeyPairSigner(); + const server = new BatchSvmScheme({ receiverAuthorizer: local }); + expect( + server.validateFacilitatorSupport?.(NETWORK, supported({ feePayer: other.address }), []), + ).toBeUndefined(); + await expect( + server.enhancePaymentRequirements( + requirements(), + supported({ feePayer: other.address, receiverAuthorizer: other.address }), + [], + ), + ).resolves.toMatchObject({ + extra: { feePayer: other.address, receiverAuthorizer: local.address }, + }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.server.lifecycle.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.server.lifecycle.test.ts new file mode 100644 index 0000000000..feb2ff093e --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.server.lifecycle.test.ts @@ -0,0 +1,1079 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentPayload, PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { buildDepositPayload, buildRefundPayload } from "../../src/batch-settlement/client/channel"; +import { verifyCloseAuthorization } from "../../src/batch-settlement/closeAuthorization"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { BatchSvmScheme } from "../../src/batch-settlement/server/scheme"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import type { ChannelState } from "../../src/batch-settlement/server/types"; +import { + isBatchFacilitatorPayload, + isBatchPayload, + type BatchChannelConfig, + type BatchPayload, +} from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; + +let payer: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; +let channelId: string; +let channelConfig: BatchChannelConfig; +let depositPayload: Extract; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + const built = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 123n, + payer, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + channelId = built.channelId; + channelConfig = built.payload.channelConfig; + depositPayload = built.payload; +}); + +function requirements(overrides: Partial = {}): PaymentRequirements { + return { + amount: "1000", + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: RECEIVER, + scheme: "batch-settlement", + ...overrides, + }; +} + +function state(overrides: Partial = {}): ChannelState { + return { + channelConfig, + channelId, + chargedCumulativeAmount: 0n, + deposit: 10_000n, + feePayer: feePayer.address, + mint: MINT, + onchainSyncedAt: Date.now(), + openSlot: 123n, + payer: payer.address, + payerAuthorizer: payer.address, + payoutWatermark: 0n, + receiver: RECEIVER, + salt: 0n, + settled: 0n, + signedMaxClaimable: 0n, + status: "open", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + ...overrides, + }; +} + +type ServerInternals = { + validatePayload(raw: BatchPayload, requirements: PaymentRequirements): Promise; + applySnapshot( + channelId: string, + snapshot: { + channelId?: string; + balance?: bigint; + totalClaimed: bigint; + withdrawRequestedAt: number; + }, + ): boolean; + persistSnapshot( + channelId: string, + raw: BatchPayload, + requirements: PaymentRequirements, + snapshot: { + channelId?: string; + balance?: bigint; + totalClaimed: bigint; + withdrawRequestedAt: number; + }, + ): Promise; + recoveredState( + raw: BatchPayload, + requirements: PaymentRequirements, + channelId: string, + snapshot: { balance?: bigint; totalClaimed: bigint; withdrawRequestedAt: number }, + ): ChannelState; + provisionalState( + raw: BatchPayload, + requirements: PaymentRequirements, + channelId: string, + ): ChannelState; + assertStoredConfig( + value: ChannelState, + config: BatchChannelConfig, + requirements: PaymentRequirements, + ): void; +}; + +function internals(server: BatchSvmScheme): ServerInternals { + return server as unknown as ServerInternals; +} + +describe("batch server lifecycle boundaries", () => { + it("rejects unknown payload discriminators and malformed claim entries", () => { + expect(isBatchPayload({ channelConfig, type: "unknown" })).toBe(false); + expect(isBatchFacilitatorPayload({ claims: [null], type: "claim" })).toBe(false); + expect( + isBatchFacilitatorPayload({ claims: [{ channelId: 1, voucher: {} }], type: "claim" }), + ).toBe(false); + }); + + it("validates every immutable payload binding", async () => { + const api = internals(new BatchSvmScheme({ receiverAuthorizer })); + await expect(api.validatePayload(depositPayload, requirements())).resolves.toBe(channelId); + const invalid: Array<[BatchPayload, PaymentRequirements, string]> = [ + [depositPayload, requirements({ extra: undefined }), BatchError.PAYMENT_FLOW], + [ + depositPayload, + requirements({ extra: { ...requirements().extra, paymentFlow: "upfront" } }), + BatchError.PAYMENT_FLOW, + ], + [ + depositPayload, + requirements({ extra: { ...requirements().extra, feePayer: undefined } }), + BatchError.FEE_PAYER_MISMATCH, + ], + [ + { ...depositPayload, channelConfig: { ...channelConfig, payer: feePayer.address } }, + requirements(), + BatchError.FEE_PAYER_MISMATCH, + ], + [ + { + ...depositPayload, + channelConfig: { ...channelConfig, payerAuthorizer: feePayer.address }, + }, + requirements(), + BatchError.FEE_PAYER_MISMATCH, + ], + [ + { ...depositPayload, channelConfig: { ...channelConfig, receiver: payer.address } }, + requirements(), + BatchError.CHANNEL_STATE, + ], + [ + { ...depositPayload, channelConfig: { ...channelConfig, token: RECEIVER } }, + requirements(), + BatchError.CHANNEL_STATE, + ], + [ + { ...depositPayload, channelConfig: { ...channelConfig, withdrawDelay: 901 } }, + requirements(), + BatchError.WITHDRAW_DELAY_MISMATCH, + ], + [ + { + ...depositPayload, + channelConfig: { ...channelConfig, receiverAuthorizer: payer.address }, + }, + requirements(), + BatchError.RECEIVER_AUTHORIZER_MISMATCH, + ], + [ + depositPayload, + requirements({ extra: { ...requirements().extra, receiverAuthorizer: payer.address } }), + BatchError.RECEIVER_AUTHORIZER_MISMATCH, + ], + [ + depositPayload, + requirements({ extra: { ...requirements().extra, tokenProgram: payer.address } }), + BatchError.TOKEN_PROGRAM, + ], + [ + { ...depositPayload, voucher: { ...depositPayload.voucher, channelId: payer.address } }, + requirements(), + BatchError.CHANNEL_ID_MISMATCH, + ], + [ + { ...depositPayload, voucher: { ...depositPayload.voucher, expiresAt: 1 } }, + requirements(), + BatchError.VOUCHER_EXPIRY, + ], + [ + { + ...depositPayload, + voucher: { ...depositPayload.voucher, maxClaimableAmount: "1001" }, + }, + requirements(), + BatchError.VOUCHER_SIGNATURE, + ], + ]; + for (const [payload, req, reason] of invalid) { + await expect(api.validatePayload(payload, req)).rejects.toThrow(reason); + } + }); + + it("accepts and rejects verified snapshot boundaries", () => { + const api = internals(new BatchSvmScheme({ receiverAuthorizer })); + expect( + api.applySnapshot(channelId, { + channelId, + balance: 10_000n, + totalClaimed: 1_000n, + withdrawRequestedAt: 0, + }), + ).toBe(true); + expect( + api.applySnapshot(channelId, { + channelId: payer.address, + totalClaimed: 0n, + withdrawRequestedAt: 0, + }), + ).toBe(false); + expect(api.applySnapshot(channelId, { totalClaimed: 0n, withdrawRequestedAt: 1 })).toBe(false); + expect( + api.applySnapshot(channelId, { + balance: 1n, + totalClaimed: 2n, + withdrawRequestedAt: 0, + }), + ).toBe(false); + expect(api.applySnapshot(channelId, { totalClaimed: 2n, withdrawRequestedAt: 0 })).toBe(true); + }); + + it("creates provisional/recovered records and merges snapshots monotonically", async () => { + const store = new MemoryChannelStore(); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const api = internals(server); + expect( + api.recoveredState(depositPayload, requirements(), channelId, { + totalClaimed: 2_000n, + withdrawRequestedAt: 0, + }), + ).toMatchObject({ deposit: 0n, settled: 2_000n, signedMaxClaimable: 2_000n }); + expect(api.provisionalState(depositPayload, requirements(), channelId)).toMatchObject({ + deposit: 10_000n, + settled: 0n, + }); + expect(() => + api.provisionalState( + { channelConfig, type: "refund", voucher: depositPayload.voucher }, + requirements(), + channelId, + ), + ).toThrow(BatchError.CHANNEL_STATE); + + await store.put(state({ chargedCumulativeAmount: 3_000n, deposit: 10_000n, settled: 2_000n })); + await api.persistSnapshot(channelId, depositPayload, requirements(), { + balance: 12_000n, + totalClaimed: 4_000n, + withdrawRequestedAt: 20, + }); + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 4_000n, + closeRequestedAt: 20, + deposit: 12_000n, + settled: 4_000n, + signedMaxClaimable: 4_000n, + status: "closing", + }); + await api.persistSnapshot(channelId, depositPayload, requirements(), { + balance: 1n, + totalClaimed: 1n, + withdrawRequestedAt: 0, + }); + expect(await store.get(channelId)).toMatchObject({ deposit: 12_000n, settled: 4_000n }); + }); + + it("rejects a stored channel with different immutable configuration", () => { + const api = internals(new BatchSvmScheme({ receiverAuthorizer })); + expect(() => api.assertStoredConfig(state(), channelConfig, requirements())).not.toThrow(); + expect(() => + api.assertStoredConfig(state(), { ...channelConfig, salt: "1" }, requirements()), + ).toThrow(BatchError.CHANNEL_STATE); + }); + + it("covers hook no-op and missing-reservation paths", async () => { + const server = new BatchSvmScheme({ receiverAuthorizer }); + const payment = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + } as PaymentPayload; + await expect( + server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: { ...payment, payload: { type: "other" } } as never, + requirements: requirements(), + }), + ).resolves.toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + result: { isValid: false, payer: payer.address }, + }), + ).resolves.toBeUndefined(); + await expect( + server.schemeHooks.onBeforeSettle!({ + declaredExtensions: {}, + paymentPayload: payment, + phase: "before-handler", + requirements: requirements(), + }), + ).resolves.toMatchObject({ abort: true, reason: "duplicate_settlement" }); + await expect( + server.schemeHooks.onAfterSettle!({ + declaredExtensions: {}, + paymentPayload: payment, + phase: "after-handler", + requirements: requirements(), + result: { network: SOLANA_DEVNET_CAIP2, success: false, transaction: "" }, + }), + ).resolves.toBeUndefined(); + }); + + it("only enriches a validated cumulative mismatch with stored proof", async () => { + const store = new MemoryChannelStore(); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + } as PaymentPayload; + const enrich = (overrides: Record = {}) => + server.enrichPaymentRequiredResponse({ + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + paymentPayload: payment, + requirements: [requirements()], + x402Version: 2, + ...overrides, + } as never); + await expect(enrich({ error: "other" })).resolves.toBeUndefined(); + await expect(enrich({ paymentPayload: undefined })).resolves.toBeUndefined(); + await expect( + enrich({ paymentPayload: { ...payment, payload: { nope: true } } }), + ).resolves.toBeUndefined(); + await expect(enrich()).resolves.toBeUndefined(); + + await store.put(state()); + await expect( + enrich({ requirements: [{ ...requirements(), scheme: "exact" }] }), + ).resolves.toBeUndefined(); + const enriched = await enrich(); + expect(enriched?.[0].extra).toMatchObject({ + channelState: { chargedCumulativeAmount: "0", channelId }, + }); + expect(enriched?.[0].extra).not.toHaveProperty("voucherState"); + + await store.put( + state({ + highestVoucherExpiresAt: 0, + highestVoucherSignature: depositPayload.voucher.signature, + signedMaxClaimable: 1_000n, + }), + ); + await expect(enrich()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extra: expect.objectContaining({ + voucherState: expect.objectContaining({ signedMaxClaimable: "1000" }), + }), + }), + ]), + ); + await store.put( + state({ + closeRequestedAt: 20, + highestVoucherExpiresAt: undefined, + highestVoucherSignature: depositPayload.voucher.signature, + signedMaxClaimable: 1_000n, + }), + ); + await expect(enrich()).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + extra: expect.objectContaining({ + channelState: expect.objectContaining({ withdrawRequestedAt: 20 }), + voucherState: expect.objectContaining({ expiresAt: 0 }), + }), + }), + ]), + ); + }); + + it("handles unknown channels and rejects unusable facilitator snapshots", async () => { + const store = new MemoryChannelStore(); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const voucherPayload: BatchPayload = { + channelConfig, + type: "voucher", + voucher: depositPayload.voucher, + }; + const payment = { + accepted: requirements(), + payload: voucherPayload, + x402Version: 2, + } as PaymentPayload; + const verifyContext = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }; + await expect(server.schemeHooks.onBeforeVerify!(verifyContext)).resolves.toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + ...verifyContext, + result: { + extra: { + balance: "1", + channelId, + totalClaimed: "2", + withdrawRequestedAt: 0, + }, + isValid: true, + payer: payer.address, + }, + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + + const second = new BatchSvmScheme({ receiverAuthorizer, store: new MemoryChannelStore() }); + const payment2 = { ...payment, payload: { ...voucherPayload } } as PaymentPayload; + const context2 = { ...verifyContext, paymentPayload: payment2 }; + await second.schemeHooks.onBeforeVerify!(context2); + await expect( + second.schemeHooks.onAfterVerify!({ + ...context2, + result: { + extra: { + balance: "10000", + channelId, + totalClaimed: "500", + withdrawRequestedAt: 0, + }, + isValid: true, + payer: payer.address, + }, + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CUMULATIVE_AMOUNT_MISMATCH }); + }); + + it("rejects an exact replay before the resource handler", async () => { + const replayState = state({ + chargedCumulativeAmount: 1_000n, + highestVoucherSignature: depositPayload.voucher.signature, + signedMaxClaimable: 1_000n, + }); + const makePayment = (): PaymentPayload => ({ + accepted: requirements(), + payload: { + channelConfig, + type: "voucher", + voucher: depositPayload.voucher, + }, + x402Version: 2, + }); + const store = new MemoryChannelStore(); + await store.put(replayState); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment = makePayment(); + await expect( + server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }), + ).resolves.toMatchObject({ abort: true, reason: "duplicate_settlement" }); + }); + + it("uses local voucher verification only while the onchain snapshot is fresh", async () => { + const freshStore = new MemoryChannelStore(); + await freshStore.put(state()); + const payment: PaymentPayload = { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher: depositPayload.voucher }, + x402Version: 2, + }; + const freshContext = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }; + await expect( + new BatchSvmScheme({ receiverAuthorizer, onchainStateTtlMs: 1_000, store: freshStore }) + .schemeHooks.onBeforeVerify!(freshContext), + ).resolves.toMatchObject({ skip: true }); + + const staleStore = new MemoryChannelStore(); + await staleStore.put(state({ onchainSyncedAt: 0 })); + const staleServer = new BatchSvmScheme({ + receiverAuthorizer, + onchainStateTtlMs: 1_000, + store: staleStore, + }); + const stalePayment = { ...payment, payload: { ...payment.payload } } as PaymentPayload; + const staleContext = { ...freshContext, paymentPayload: stalePayment }; + await expect(staleServer.schemeHooks.onBeforeVerify!(staleContext)).resolves.toBeUndefined(); + await expect( + staleServer.schemeHooks.onAfterVerify!({ + ...staleContext, + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + const beforeRefresh = Date.now(); + await expect( + staleServer.schemeHooks.onAfterVerify!({ + ...staleContext, + result: { + extra: { + balance: "10000", + channelId, + totalClaimed: "0", + withdrawRequestedAt: 0, + }, + isValid: true, + payer: payer.address, + }, + }), + ).resolves.toBeUndefined(); + expect((await staleStore.get(channelId))?.onchainSyncedAt).toBeGreaterThanOrEqual( + beforeRefresh, + ); + }); + + it("rejects busy, closing, and mismatched stored channel reservations", async () => { + const cases = [ + state({ + reservations: { + busy: { ceiling: 1n, expiresAt: Date.now() + 10_000, kind: "client" }, + }, + }), + state({ status: "closing" }), + state({ channelConfig: { ...channelConfig, salt: "1" } }), + ]; + for (const stored of cases) { + const store = new MemoryChannelStore(); + await store.put(stored); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + const before = await server.schemeHooks.onBeforeVerify!(ctx); + if (stored.channelConfig.salt !== channelConfig.salt) { + expect(before).toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + continue; + } + expect(before).toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toMatchObject({ abort: true }); + } + }); + + it("covers price parser and requirement configuration branches", async () => { + const server = new BatchSvmScheme({ receiverAuthorizer }); + await expect( + server.parsePrice({ amount: "1", asset: MINT }, SOLANA_DEVNET_CAIP2), + ).resolves.toEqual({ + amount: "1", + asset: MINT, + extra: {}, + }); + await expect( + server.parsePrice({ amount: "1", asset: "" }, SOLANA_DEVNET_CAIP2), + ).rejects.toThrow(/Asset address/); + await expect(server.parsePrice(1, SOLANA_DEVNET_CAIP2)).resolves.toMatchObject({ + amount: "1000000", + }); + await expect(server.parsePrice("1 USD", SOLANA_DEVNET_CAIP2)).resolves.toMatchObject({ + asset: MINT, + }); + await expect(server.parsePrice("not money", SOLANA_DEVNET_CAIP2)).rejects.toThrow( + /Invalid money format/, + ); + const custom = new BatchSvmScheme({ receiverAuthorizer }) + .registerMoneyParser(async () => null) + .registerMoneyParser(async () => ({ amount: "7", asset: MINT })); + await expect(custom.parsePrice("2", SOLANA_DEVNET_CAIP2)).resolves.toEqual({ + amount: "7", + asset: MINT, + }); + await expect( + server.enhancePaymentRequirements( + requirements(), + { network: SOLANA_DEVNET_CAIP2, scheme: "batch-settlement", x402Version: 2 }, + [], + ), + ).resolves.toMatchObject({ + extra: { receiverAuthorizer: receiverAuthorizer.address, withdrawDelay: 900 }, + }); + expect(() => + new BatchSvmScheme({ + receiverAuthorizer, + withdrawDelay: 2_592_001, + }).enhancePaymentRequirements( + requirements(), + { network: SOLANA_DEVNET_CAIP2, scheme: "batch-settlement", x402Version: 2 }, + [], + ), + ).toThrow(BatchError.WITHDRAW_DELAY_OUT_OF_RANGE); + }); + + it("normalizes malformed verify snapshots without trusting their fields", async () => { + const snapshots = [ + undefined, + null, + { totalClaimed: "bad", withdrawRequestedAt: 0 }, + { balance: "bad", totalClaimed: "0", withdrawRequestedAt: 1.5 }, + { totalClaimed: "0", withdrawRequestedAt: "bad" }, + ]; + for (const channelState of snapshots) { + const store = new MemoryChannelStore(); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + await server.schemeHooks.onBeforeVerify!(ctx); + const result = await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { + extra: channelState === undefined ? undefined : (channelState as Record), + isValid: true, + payer: payer.address, + }, + }); + const snapshotWithoutBalance = + typeof channelState === "object" && + channelState !== null && + typeof channelState.totalClaimed === "string" && + /^\d+$/.test(channelState.totalClaimed) && + !(typeof channelState.balance === "string" && /^\d+$/.test(channelState.balance)); + if (snapshotWithoutBalance) { + expect(result).toMatchObject({ + abort: true, + reason: BatchError.CUMULATIVE_EXCEEDS_DEPOSIT, + }); + } else { + expect(result).toBeUndefined(); + } + expect(await store.get(channelId)).toBeDefined(); + } + }); + + it("normalizes settlement snapshots and never lowers a confirmed deposit", async () => { + const responses = [ + undefined, + null, + { balance: "bad", totalClaimed: 1, withdrawRequestedAt: "bad" }, + { balance: "1", totalClaimed: "0", withdrawRequestedAt: 0 }, + { balance: "12000", totalClaimed: "0", withdrawRequestedAt: 0 }, + ]; + for (const channelState of responses) { + const store = new MemoryChannelStore(); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + const before = await server.schemeHooks.onBeforeVerify!(ctx); + expect(before).toBeUndefined(); + await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }); + await server.schemeHooks.onAfterSettle!({ + ...ctx, + phase: "after-handler", + result: { + extra: channelState === undefined ? undefined : { channelState }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "signature", + }, + }); + const stored = await store.get(channelId); + expect(stored?.deposit).toBe( + channelState && channelState.balance === "12000" ? 12_000n : 10_000n, + ); + } + }); + + it("commits voucher reservations before settlement and detects replacement", async () => { + const store = new MemoryChannelStore(); + await store.put(state()); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: { + channelConfig, + type: "voucher", + voucher: depositPayload.voucher, + }, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + await server.schemeHooks.onBeforeVerify!(ctx); + await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }); + await expect( + server.schemeHooks.onBeforeSettle!({ ...ctx, phase: "before-handler" }), + ).resolves.toMatchObject({ + skip: true, + result: { extra: { commitmentId: `${channelId}:1000` }, success: true }, + }); + expect(await store.get(channelId)).toMatchObject({ chargedCumulativeAmount: 1_000n }); + + const changedStore = new MemoryChannelStore(); + await changedStore.put(state()); + const changed = new BatchSvmScheme({ receiverAuthorizer, store: changedStore }); + const payment2 = { ...payment, payload: { ...payment.payload } } as PaymentPayload; + const ctx2 = { ...ctx, paymentPayload: payment2 }; + await changed.schemeHooks.onBeforeVerify!(ctx2); + await changed.schemeHooks.onAfterVerify!({ + ...ctx2, + result: { isValid: true, payer: payer.address }, + }); + await changedStore.update(channelId, current => ({ + ...current!, + reservations: { + replacement: { ceiling: 1n, expiresAt: Date.now() + 10_000, kind: "client" }, + }, + })); + await expect( + changed.schemeHooks.onBeforeSettle!({ ...ctx2, phase: "before-handler" }), + ).resolves.toMatchObject({ abort: true, reason: "duplicate_settlement" }); + }); + + it("covers corrective validation and hook state edge cases", async () => { + const server = new BatchSvmScheme({ receiverAuthorizer }); + const invalidPayment: PaymentPayload = { + accepted: requirements(), + payload: { + ...depositPayload, + voucher: { ...depositPayload.voucher, maxClaimableAmount: "1001" }, + }, + x402Version: 2, + }; + await expect( + server.enrichPaymentRequiredResponse({ + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + paymentPayload: invalidPayment, + requirements: [requirements()], + x402Version: 2, + } as never), + ).resolves.toBeUndefined(); + + const thrown = new BatchSvmScheme({ receiverAuthorizer }); + internals(thrown).validatePayload = async () => Promise.reject("validation failed"); + await expect( + thrown.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: { accepted: requirements(), payload: depositPayload, x402Version: 2 }, + requirements: requirements(), + }), + ).resolves.toMatchObject({ + abort: true, + message: "validation failed", + reason: "transaction_failed", + }); + + const noRequest = new BatchSvmScheme({ receiverAuthorizer }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: depositPayload, + x402Version: 2, + }; + await expect( + noRequest.schemeHooks.onAfterVerify!({ + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + await expect( + noRequest.schemeHooks.onAfterSettle!({ + declaredExtensions: {}, + paymentPayload: payment, + phase: "after-handler", + requirements: requirements(), + result: { network: SOLANA_DEVNET_CAIP2, success: true, transaction: "sig" }, + }), + ).resolves.toBeUndefined(); + await expect( + noRequest.schemeHooks.onSettleFailure!({ + declaredExtensions: {}, + error: new Error("handler"), + paymentPayload: payment, + phase: "after-handler", + requirements: requirements(), + } as never), + ).resolves.toBeUndefined(); + }); + + it("reserves refunds but never runs them through pre-handler settlement", async () => { + const store = new MemoryChannelStore(); + await store.put( + state({ + chargedCumulativeAmount: 1_000n, + highestVoucherSignature: depositPayload.voucher.signature, + signedMaxClaimable: 1_000n, + }), + ); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const refund = await buildRefundPayload({ + channelConfig, + channelId, + feePayer: feePayer.address, + payer, + voucher: depositPayload.voucher, + }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: refund, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + await expect(server.schemeHooks.onBeforeVerify!(ctx)).resolves.toBeUndefined(); + await expect( + server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toMatchObject({ + skipHandler: true, + response: { body: { channelId, message: "Refund initiated" } }, + }); + await expect( + server.schemeHooks.onBeforeSettle!({ ...ctx, phase: "before-handler" }), + ).resolves.toBeUndefined(); + + const enriched = await server.enrichSettlementPayload(ctx as never); + expect(enriched).toHaveProperty("closeAuthorization"); + await expect( + verifyCloseAuthorization( + (enriched as { closeAuthorization: never }).closeAuthorization, + { + channelId, + feePayer: feePayer.address, + maxClaimableAmount: 1_000n, + network: SOLANA_DEVNET_CAIP2, + voucherExpiresAt: BigInt(depositPayload.voucher.expiresAt), + }, + receiverAuthorizer.address, + 300, + ), + ).resolves.toBe(true); + }); + + it("closes a cooperative refund at once and seals a fallback refund on the next pass", async () => { + const refund = async (withdrawRequestedAt: number) => { + const store = new MemoryChannelStore(); + await store.put( + state({ + chargedCumulativeAmount: 1_000n, + highestVoucherSignature: depositPayload.voucher.signature, + signedMaxClaimable: 1_000n, + }), + ); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const payload = await buildRefundPayload({ + channelConfig, + channelId, + feePayer: feePayer.address, + payer, + voucher: depositPayload.voucher, + }); + const ctx = { + declaredExtensions: {}, + paymentPayload: { accepted: requirements(), payload, x402Version: 2 }, + requirements: requirements(), + }; + await server.schemeHooks.onBeforeVerify!(ctx); + await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }); + await server.schemeHooks.onAfterSettle!({ + ...ctx, + result: { + extra: { + channelState: { + balance: "10000", + channelId, + totalClaimed: withdrawRequestedAt === 0 ? "1000" : "0", + withdrawRequestedAt, + }, + }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "close-signature", + }, + } as never); + return { server, store }; + }; + + const cooperative = await refund(0); + expect(await cooperative.store.get(channelId)).toMatchObject({ + payoutWatermark: 1_000n, + settled: 1_000n, + status: "distributed", + }); + + const closeRequestedAt = Math.floor(Date.now() / 1000); + const fallback = await refund(closeRequestedAt); + expect(await fallback.store.get(channelId)).toMatchObject({ + closeRequestedAt, + settled: 0n, + status: "closing", + }); + const seen: string[] = []; + const manager = fallback.server.createChannelManager( + { + settle: async payment => { + seen.push((payment.payload as { type: string }).type); + return { network: SOLANA_DEVNET_CAIP2, success: true, transaction: "seal-signature" }; + }, + }, + requirements(), + { + readPayoutWatermark: async () => 1_000n, + }, + ); + await expect(manager.redeem()).resolves.toMatchObject({ sealed: [channelId] }); + expect(seen).toEqual(["seal"]); + expect(await fallback.store.get(channelId)).toMatchObject({ + settled: 1_000n, + status: "distributed", + }); + }); + + it("marks a channel closing when the facilitator reports channel_closing", async () => { + const closing: string[] = []; + const store = new MemoryChannelStore(); + await store.put(state({ onchainSyncedAt: 0 })); + const server = new BatchSvmScheme({ + onChannelClosing: id => closing.push(id), + receiverAuthorizer, + store, + }); + const ctx = { + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher: depositPayload.voucher }, + x402Version: 2, + }, + requirements: requirements(), + }; + await expect(server.schemeHooks.onBeforeVerify!(ctx)).resolves.toBeUndefined(); + await server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { invalidReason: BatchError.CHANNEL_CLOSING, isValid: false }, + }); + expect((await store.get(channelId))?.status).toBe("closing"); + expect(closing).toEqual([channelId]); + }); + + it("refuses a refund voucher that is not the charged cumulative amount", async () => { + const store = new MemoryChannelStore(); + await store.put(state({ chargedCumulativeAmount: 2_000n })); + const server = new BatchSvmScheme({ receiverAuthorizer, store }); + const refund = await buildRefundPayload({ + channelConfig, + channelId, + feePayer: feePayer.address, + payer, + voucher: depositPayload.voucher, + }); + const payment: PaymentPayload = { accepted: requirements(), payload: refund, x402Version: 2 }; + await expect( + server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CUMULATIVE_AMOUNT_MISMATCH }); + }); + + it("refuses a first deposit whose open binds a different receiver authorizer", async () => { + const attacker = await buildDepositPayload({ + blockhash: { blockhash: RECEIVER, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: 123n, + payer, + receiver: RECEIVER, + receiverAuthorizer: payer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + const payload = { + ...attacker.payload, + channelConfig: { + ...attacker.payload.channelConfig, + receiverAuthorizer: receiverAuthorizer.address, + }, + }; + const server = new BatchSvmScheme({ receiverAuthorizer }); + await expect( + server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: { accepted: requirements(), payload, x402Version: 2 }, + requirements: requirements(), + }), + ).resolves.toMatchObject({ + abort: true, + reason: expect.stringContaining(BatchError.RECEIVER_AUTHORIZER_MISMATCH), + }); + }); + + it("rejects an unknown voucher when no verified snapshot creates state", async () => { + const server = new BatchSvmScheme({ receiverAuthorizer }); + const payment: PaymentPayload = { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher: depositPayload.voucher }, + x402Version: 2, + }; + const ctx = { declaredExtensions: {}, paymentPayload: payment, requirements: requirements() }; + await server.schemeHooks.onBeforeVerify!(ctx); + await expect( + server.schemeHooks.onAfterVerify!({ + ...ctx, + result: { isValid: true, payer: payer.address }, + }), + ).resolves.toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.test.ts new file mode 100644 index 0000000000..9286466cae --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.test.ts @@ -0,0 +1,1438 @@ +import { generateKeyPairSigner, getTransactionDecoder, getBase64Codec } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { BatchError } from "../../src/batch-settlement/errors"; +import { + encodeBatchAuthorizationMessage, + signBatchAuthorization, + verifyBatchAuthorization, +} from "../../src/batch-settlement/authorization"; +import { + BatchChannelTracker, + buildDepositPayload, + buildRefundPayload, + signBatchVoucher, +} from "../../src/batch-settlement/client/channel"; +import { + BatchSvmScheme as BatchClientScheme, + type BatchClientChannelStorage, +} from "../../src/batch-settlement/client/scheme"; +import { + BatchSvmScheme as BatchFacilitatorScheme, + calculateDistributionAmount, +} from "../../src/batch-settlement/facilitator/scheme"; +import { InMemoryBatchReceiverAuthorizerStore } from "../../src/batch-settlement/facilitator/receiverAuthorizerStore"; +import { BatchSvmScheme as BatchServerScheme } from "../../src/batch-settlement/server/scheme"; +import { encodeReceiverBindingMemo } from "../../src/batch-settlement/receiverBinding"; +import { MemoryChannelStore } from "../../src/batch-settlement/server/storage"; +import type { ChannelState } from "../../src/batch-settlement/server/types"; +import { + isBatchPayload, + isBatchFacilitatorPayload, + isBatchVoucher, + type BatchChannelConfig, + type BatchVoucher, +} from "../../src/batch-settlement/types"; +import { + SOLANA_DEVNET_CAIP2, + SOLANA_MAINNET_CAIP2, + TOKEN_PROGRAM_ADDRESS, +} from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { + buildRequestCloseTransaction, + verifyRequestCloseTransaction, +} from "../../src/payment-channels/close"; +import { SEAL_DISCRIMINATOR } from "../../src/payment-channels/generated/instructions/seal"; +import { SETTLE_DISCRIMINATOR } from "../../src/payment-channels/generated/instructions/settle"; +import { buildSealInstruction, buildSettleInstructions } from "../../src/payment-channels/onchain"; +import { verifyOpenTransaction } from "../../src/payment-channels/open"; +import { + encodeVoucherMessageBytes, + verifyVoucherSignature, +} from "../../src/payment-channels/voucher"; +import { type FacilitatorSvmSigner, toFacilitatorSvmSigner } from "../../src/signer"; + +const DUMMY_BLOCKHASH = USDC_MAINNET_ADDRESS; +const MINT = USDC_DEVNET_ADDRESS; +const RECEIVER = USDC_MAINNET_ADDRESS; +const OPEN_SLOT = 123_456_789n; +const WITHDRAW_DELAY = 900; + +let payer: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; +let channelId: string; +let channelConfig: BatchChannelConfig; +let openTransaction: string; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + const built = await buildDepositPayload({ + blockhash: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: OPEN_SLOT, + payer, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }); + channelId = built.channelId; + channelConfig = built.payload.channelConfig; + openTransaction = built.payload.deposit.transaction; +}); + +function requirements(amount = "1000"): PaymentRequirements { + return { + amount, + asset: MINT, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: RECEIVER, + scheme: "batch-settlement", + }; +} + +function serverState(overrides: Partial = {}): ChannelState { + return { + channelConfig, + channelId, + chargedCumulativeAmount: 0n, + deposit: 10_000n, + feePayer: feePayer.address, + mint: MINT, + onchainSyncedAt: Date.now(), + openSlot: OPEN_SLOT, + payer: payer.address, + payerAuthorizer: payer.address, + payoutWatermark: 0n, + receiver: RECEIVER, + salt: BigInt(channelConfig.salt), + settled: 0n, + signedMaxClaimable: 0n, + status: "open", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + ...overrides, + }; +} + +async function signedVoucher(maxClaimableAmount: bigint, expiresAt = 0): Promise { + return signBatchVoucher(payer, { channelId, expiresAt, maxClaimableAmount }); +} + +/** Avoid devnet RPC in verify paths that call `resolveTerms` (mint owner read). */ +function batchFacilitatorSigner(feePayerSigner: typeof feePayer): FacilitatorSvmSigner { + return { + ...toFacilitatorSvmSigner(feePayerSigner), + getAccountInfo: async () => ({ + data: "AA==", + lamports: 0n, + owner: TOKEN_PROGRAM_ADDRESS, + }), + }; +} + +describe("batch-settlement SVM", () => { + describe("resource server", () => { + it("parses stablecoin prices", async () => { + const server = new BatchServerScheme({ receiverAuthorizer }); + expect(await server.parsePrice("$0.001", SOLANA_MAINNET_CAIP2)).toMatchObject({ + amount: "1000", + asset: USDC_MAINNET_ADDRESS, + }); + expect(await server.parsePrice("1.00", SOLANA_DEVNET_CAIP2)).toMatchObject({ + amount: "1000000", + asset: USDC_DEVNET_ADDRESS, + }); + }); + + it("publishes the authorization/channel requirements", async () => { + const server = new BatchServerScheme({ receiverAuthorizer, withdrawDelay: 1_200 }); + const enhanced = await server.enhancePaymentRequirements( + requirements(), + { + extra: { feePayer: feePayer.address }, + network: SOLANA_DEVNET_CAIP2, + scheme: "batch-settlement", + x402Version: 2, + }, + [], + ); + expect(server.defaultAssetTransferMethod).toBe("channel"); + expect(server.paymentFlows.channel.default).toBe("authorization"); + expect(enhanced.extra).toMatchObject({ + feePayer: feePayer.address, + minDeposit: "10000", + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 1_200, + }); + expect(enhanced.extra).not.toHaveProperty("paymentFlow"); + }); + + it("publishes route minDeposit overrides and optionally enforces them", async () => { + const routeRequirements = requirements(); + routeRequirements.extra = { ...routeRequirements.extra, minDeposit: "$0.02" }; + const hinted = await new BatchServerScheme({ receiverAuthorizer }).enhancePaymentRequirements( + routeRequirements, + { + extra: { feePayer: feePayer.address }, + network: SOLANA_DEVNET_CAIP2, + scheme: "batch-settlement", + x402Version: 2, + }, + [], + ); + expect(hinted.extra?.minDeposit).toBe("20000"); + + const atomicFloor = requirements(); + atomicFloor.extra = { ...atomicFloor.extra, minDeposit: "500" }; + expect(new BatchServerScheme({ receiverAuthorizer }).resolveMinDepositHint(atomicFloor)).toBe( + "1000", + ); + const zeroFloor = requirements(); + zeroFloor.extra = { ...zeroFloor.extra, minDeposit: "0" }; + expect(() => + new BatchServerScheme({ receiverAuthorizer }).resolveMinDepositHint(zeroFloor), + ).toThrow(/positive/); + const wrongCurrency = requirements(); + wrongCurrency.extra = { ...wrongCurrency.extra, minDeposit: "1 USDT" }; + expect(() => + new BatchServerScheme({ receiverAuthorizer }).resolveMinDepositHint(wrongCurrency), + ).toThrow(/currency must match USDC/); + const customAsset = requirements(); + customAsset.asset = payer.address; + customAsset.extra = { ...customAsset.extra, minDeposit: "$1" }; + expect(() => + new BatchServerScheme({ receiverAuthorizer }).resolveMinDepositHint(customAsset), + ).toThrow(/only supported for default assets/); + + const payment = { + accepted: hinted, + payload: { + channelConfig, + deposit: { amount: "10000", transaction: openTransaction }, + type: "deposit" as const, + voucher: await signedVoucher(1_000n), + }, + x402Version: 2, + }; + const context = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: hinted, + }; + await expect( + new BatchServerScheme({ receiverAuthorizer }).schemeHooks.onBeforeVerify!(context), + ).resolves.toBeUndefined(); + await expect( + new BatchServerScheme({ receiverAuthorizer, enforceMinDeposit: true }).schemeHooks + .onBeforeVerify!(context), + ).resolves.toMatchObject({ + abort: true, + reason: BatchError.DEPOSIT_BELOW_MIN_DEPOSIT, + }); + }); + + it("publishes the configured operator voucher signer", async () => { + const operator = await generateKeyPairSigner(); + const server = new BatchServerScheme({ receiverAuthorizer, operator }); + const enhanced = await server.enhancePaymentRequirements( + requirements(), + { + extra: { feePayer: feePayer.address }, + network: SOLANA_DEVNET_CAIP2, + scheme: "batch-settlement", + x402Version: 2, + }, + [], + ); + expect(enhanced.extra).toMatchObject({ + operator: operator.address, + voucherSigner: "server", + }); + }); + + it("lets a route stay client-signed next to a configured operator", async () => { + const operator = await generateKeyPairSigner(); + const server = new BatchServerScheme({ receiverAuthorizer, operator }); + const kind = { + extra: { feePayer: feePayer.address }, + network: SOLANA_DEVNET_CAIP2, + scheme: "batch-settlement", + x402Version: 2, + }; + // The same route can list both accepts: metered for clients that trust + // the operator, the ceiling as a fixed price for everyone else. + const clientSigned = await server.enhancePaymentRequirements( + { + ...requirements(), + extra: { ...requirements().extra, operator: "stale", voucherSigner: "client" }, + }, + kind, + [], + ); + expect(clientSigned.extra).toMatchObject({ voucherSigner: "client" }); + expect(clientSigned.extra).not.toHaveProperty("operator"); + // The published deposit hint is smaller where the escrow is the + // operator's to take. + expect(clientSigned.extra?.minDeposit).toBe("10000"); + const serverSigned = await server.enhancePaymentRequirements(requirements(), kind, []); + expect(serverSigned.extra).toMatchObject({ + minDeposit: "3000", + operator: operator.address, + voucherSigner: "server", + }); + expect( + ( + await new BatchServerScheme({ receiverAuthorizer }).enhancePaymentRequirements( + requirements(), + kind, + [], + ) + ).extra?.minDeposit, + ).toBe("10000"); + expect(() => + new BatchServerScheme({ receiverAuthorizer }).enhancePaymentRequirements( + { ...requirements(), extra: { ...requirements().extra, voucherSigner: "server" } }, + kind, + [], + ), + ).toThrow(/requires an operator signer/); + expect(() => + server.enhancePaymentRequirements( + { ...requirements(), extra: { ...requirements().extra, voucherSigner: "other" } }, + kind, + [], + ), + ).toThrow(/"client" or "server"/); + }); + + it("broadcasts the deposit and commits its voucher only in the post-handler settle", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const voucher = await signedVoucher(1_000n); + const payment = { + accepted: requirements(), + payload: { + channelConfig, + deposit: { amount: "10000", transaction: openTransaction }, + type: "deposit" as const, + voucher, + }, + x402Version: 2, + }; + const verifyContext = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }; + const beforeVerify = await server.schemeHooks.onBeforeVerify!(verifyContext); + expect(beforeVerify).toBeUndefined(); + await server.schemeHooks.onAfterVerify!({ + ...verifyContext, + result: { isValid: true, payer: payer.address }, + }); + + const reserved = await store.get(channelId); + expect(reserved?.chargedCumulativeAmount).toBe(0n); + expect(Object.values(reserved?.reservations ?? {})).toEqual([ + expect.objectContaining({ ceiling: 1_000n }), + ]); + + const forwarded = await server.schemeHooks.onBeforeSettle!({ + ...verifyContext, + phase: "after-handler", + }); + expect(forwarded).toBeUndefined(); + + await server.schemeHooks.onAfterSettle!({ + ...verifyContext, + phase: "after-handler", + result: { + extra: { channelState: { totalClaimed: "0", withdrawRequestedAt: 0 } }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "open-signature", + }, + }); + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 1_000n, + openSignature: "open-signature", + reservations: {}, + signedMaxClaimable: 1_000n, + }); + }); + + it("records a top-up's escrow so the reported balance is not pinned at the open amount", async () => { + // `deposit` is written once when the channel is provisioned. If a top-up + // never reached stored state, the balance reported to the client would + // stay at the original open amount — and the client, which adopts that + // balance as its own ceiling, would top up again on the next request that + // exceeded it, and on every request after that, growing the escrow on + // chain while believing it never had. + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + + const open = async (amount: bigint, confirmedBalance: string) => { + const voucher = await signedVoucher(amount); + const payment = { + accepted: requirements(), + payload: { + channelConfig, + deposit: { amount: "10000", transaction: openTransaction }, + type: "deposit" as const, + voucher, + }, + x402Version: 2, + }; + const verifyContext = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }; + await server.schemeHooks.onBeforeVerify!(verifyContext); + await server.schemeHooks.onAfterVerify!({ + ...verifyContext, + result: { isValid: true, payer: payer.address }, + }); + await server.schemeHooks.onBeforeSettle!({ ...verifyContext, phase: "after-handler" }); + await server.schemeHooks.onAfterSettle!({ + ...verifyContext, + phase: "after-handler", + result: { + extra: { + channelState: { + balance: confirmedBalance, + totalClaimed: "0", + withdrawRequestedAt: 0, + }, + }, + network: SOLANA_DEVNET_CAIP2, + success: true, + transaction: "setup-signature", + }, + }); + }; + + // The channel opens with 10,000 escrowed. + await open(1_000n, "10000"); + expect((await store.get(channelId))?.deposit).toBe(10_000n); + + // A top-up confirms 25,000 on chain; the facilitator reports it, and the + // server must adopt it rather than keep reporting the open amount. + await open(2_000n, "25000"); + expect((await store.get(channelId))?.deposit).toBe(25_000n); + + // A stale or malformed read must never lower a ceiling the chain has + // already confirmed. + await open(3_000n, "9000"); + expect((await store.get(channelId))?.deposit).toBe(25_000n); + await open(4_000n, "not-a-number"); + expect((await store.get(channelId))?.deposit).toBe(25_000n); + }); + + it("releases a reservation without charging when the handler fails", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const voucher = await signedVoucher(1_000n); + const payment = { + accepted: requirements(), + payload: { + channelConfig, + deposit: { amount: "10000", transaction: openTransaction }, + type: "deposit" as const, + voucher, + }, + x402Version: 2, + }; + const context = { + declaredExtensions: {}, + paymentPayload: payment, + requirements: requirements(), + }; + const verified = await server.schemeHooks.onBeforeVerify!(context); + expect(verified).toBeUndefined(); + await server.schemeHooks.onAfterVerify!({ + ...context, + result: { isValid: true, payer: payer.address }, + }); + await server.schemeHooks.onVerifiedPaymentCanceled!({ + ...context, + reason: "handler_threw", + settledPhases: ["before-handler"], + }); + expect(await store.get(channelId)).toMatchObject({ + chargedCumulativeAmount: 0n, + reservations: {}, + }); + }); + + it("rebuilds an unknown channel from the verified onchain snapshot", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + // The client believes it was charged 4000; the chain has settled 2000, + // which is all this server can rebuild from. + const voucher = await signedVoucher(5_000n); + const context = { + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher }, + x402Version: 2, + }, + requirements: requirements(), + }; + + // An empty store must not short-circuit the facilitator: only it can say + // whether the voucher matches confirmed onchain state. + expect(await server.schemeHooks.onBeforeVerify!(context)).toBeUndefined(); + expect(await store.get(channelId)).toBeUndefined(); + + const verified = await server.schemeHooks.onAfterVerify!({ + ...context, + result: { + isValid: true, + payer: payer.address, + extra: { + channelId, + balance: "10000", + totalClaimed: "2000", + withdrawRequestedAt: 0, + }, + }, + }); + + const rebuilt = await store.get(channelId); + // The settled watermark is the baseline: a record starting at zero would + // accept vouchers the program can never apply. + expect(rebuilt).toMatchObject({ + chargedCumulativeAmount: 2_000n, + deposit: 10_000n, + settled: 2_000n, + signedMaxClaimable: 2_000n, + }); + // 5000 is not 2000 + 1000, so the request is refused — but now there is a + // record for the corrective 402 to report. + expect(verified).toMatchObject({ + abort: true, + reason: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + }); + }); + + it("serves the first voucher a rebuilt record expects", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const voucher = await signedVoucher(3_000n); + const context = { + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher }, + x402Version: 2, + }, + requirements: requirements(), + }; + await server.schemeHooks.onBeforeVerify!(context); + const verified = await server.schemeHooks.onAfterVerify!({ + ...context, + result: { + isValid: true, + payer: payer.address, + extra: { + channelId, + balance: "10000", + // Settled at 2000, so 2000 + 1000 is exactly what this voucher + // authorizes. + totalClaimed: "2000", + withdrawRequestedAt: 0, + }, + }, + }); + expect(verified).toBeUndefined(); + const rebuilt = await store.get(channelId); + expect(rebuilt?.chargedCumulativeAmount).toBe(2_000n); + expect(Object.values(rebuilt?.reservations ?? {})).toEqual([ + expect.objectContaining({ ceiling: 1_000n }), + ]); + }); + + it("refuses to rebuild a record for a channel that is closing", async () => { + const store = new MemoryChannelStore(); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const voucher = await signedVoucher(3_000n); + const context = { + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher }, + x402Version: 2, + }, + requirements: requirements(), + }; + await server.schemeHooks.onBeforeVerify!(context); + const verified = await server.schemeHooks.onAfterVerify!({ + ...context, + result: { + isValid: true, + payer: payer.address, + extra: { + channelId, + balance: "10000", + totalClaimed: "2000", + // A forced close is running: its grace period bounds redemption, + // so no further charge may be accepted. + withdrawRequestedAt: 1_760_000_000, + }, + }, + }); + expect(verified).toMatchObject({ abort: true, reason: BatchError.CHANNEL_STATE }); + expect(await store.get(channelId)).toBeUndefined(); + }); + + it("carries the charged base and its own proof in a corrective 402", async () => { + const store = new MemoryChannelStore(); + const held = await signedVoucher(3_000n); + await store.put( + serverState({ + chargedCumulativeAmount: 3_000n, + highestVoucherExpiresAt: 0, + highestVoucherSignature: held.signature, + settled: 2_000n, + signedMaxClaimable: 3_000n, + }), + ); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const stale = await signedVoucher(9_000n); + const paymentPayload = { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher: stale }, + x402Version: 2, + }; + const context = { declaredExtensions: {}, paymentPayload, requirements: requirements() }; + expect(await server.schemeHooks.onBeforeVerify!(context)).toMatchObject({ + abort: true, + reason: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + }); + + const accepts = [requirements()]; + const enriched = await server.enrichPaymentRequiredResponse({ + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + paymentPayload, + paymentRequiredResponse: { accepts, x402Version: 2 }, + requirements: accepts, + resourceInfo: { url: "https://example.test/paid" }, + }); + expect(enriched?.[0]?.extra).toMatchObject({ + channelState: { chargedCumulativeAmount: "3000", totalClaimed: "2000" }, + // The proof is the client's own signature at that cumulative amount, + // so the client can check the base rather than trust it. + voucherState: { expiresAt: 0, signature: held.signature, signedMaxClaimable: "3000" }, + }); + // And the proof verifies against the client's authorizer key. + const state = enriched![0]!.extra!.voucherState as { + signedMaxClaimable: string; + expiresAt: number; + signature: string; + }; + expect( + await verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: BigInt(state.signedMaxClaimable), + expiresAt: BigInt(state.expiresAt), + }), + signatureBase58: state.signature, + signerBase58: channelConfig.payerAuthorizer, + }), + ).toBe(true); + }); + + it("omits the voucher proof when it holds no accepted voucher", async () => { + const store = new MemoryChannelStore(); + // A record rebuilt from chain has a base but no signature to prove it. + await store.put(serverState({ chargedCumulativeAmount: 2_000n, settled: 2_000n })); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const stale = await signedVoucher(9_000n); + const paymentPayload = { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher: stale }, + x402Version: 2, + }; + await server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload, + requirements: requirements(), + }); + const accepts = [requirements()]; + const enriched = await server.enrichPaymentRequiredResponse({ + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + paymentPayload, + paymentRequiredResponse: { accepts, x402Version: 2 }, + requirements: accepts, + resourceInfo: { url: "https://example.test/paid" }, + }); + expect(enriched?.[0]?.extra?.channelState).toMatchObject({ + chargedCumulativeAmount: "2000", + }); + expect(enriched?.[0]?.extra?.voucherState).toBeUndefined(); + }); + + it("rejects an exact replay before the resource handler", async () => { + const store = new MemoryChannelStore(); + const voucher = await signedVoucher(1_000n); + await store.put( + serverState({ + chargedCumulativeAmount: 1_000n, + highestVoucherExpiresAt: voucher.expiresAt, + highestVoucherSignature: voucher.signature, + signedMaxClaimable: 1_000n, + }), + ); + const server = new BatchServerScheme({ receiverAuthorizer, store }); + const context = { + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher" as const, voucher }, + x402Version: 2, + }, + requirements: requirements(), + }; + await expect(server.schemeHooks.onBeforeVerify!(context)).resolves.toMatchObject({ + abort: true, + reason: "duplicate_settlement", + }); + }); + }); + + describe("wire types", () => { + it("accepts only the current nested wire shapes", async () => { + const voucher = await signedVoucher(1_000n); + expect(isBatchVoucher(voucher)).toBe(true); + expect(isBatchVoucher({ ...voucher, maxClaimableAmount: 1_000 })).toBe(false); + expect(isBatchPayload({ channelConfig, type: "voucher", voucher })).toBe(true); + expect( + isBatchPayload({ + channelConfig, + deposit: { amount: "10000", transaction: "tx" }, + type: "deposit", + voucher, + }), + ).toBe(true); + expect(isBatchPayload({ channelConfig, type: "refund", voucher })).toBe(true); + expect(isBatchPayload({ channelConfig, transaction: "tx", type: "refund", voucher })).toBe( + true, + ); + expect(isBatchPayload({ channelConfig, transaction: "tx", type: "refund" })).toBe(false); + const operator = await generateKeyPairSigner(); + const serverChannelConfig = { + ...channelConfig, + payerAuthorizer: operator.address, + voucherSigner: "server" as const, + }; + const refundAuth = await signBatchAuthorization( + payer, + channelId, + operator.address, + "refund-req", + 0n, + Math.floor(Date.now() / 1000) + 300, + ); + expect( + isBatchPayload({ + authorization: refundAuth, + channelConfig: serverChannelConfig, + type: "refund", + }), + ).toBe(true); + expect( + isBatchPayload({ + authorization: refundAuth, + channelConfig: serverChannelConfig, + type: "refund", + voucher, + }), + ).toBe(true); + expect(isBatchPayload({ channelId, type: "voucher", voucher })).toBe(false); + expect( + isBatchFacilitatorPayload({ + claims: [ + { + channelConfig, + channelId, + voucher: { + channelId, + expiresAt: voucher.expiresAt, + maxClaimableAmount: voucher.maxClaimableAmount, + signature: voucher.signature, + }, + }, + ], + type: "claim", + }), + ).toBe(true); + expect(isBatchFacilitatorPayload({ claims: [], type: "claim" })).toBe(false); + }); + }); + + describe("client construction", () => { + it("signs the canonical 50-byte voucher message", async () => { + const voucher = await signedVoucher(5_000n); + expect(voucher.maxClaimableAmount).toBe("5000"); + await expect( + verifyVoucherSignature({ + message: encodeVoucherMessageBytes({ + channelId, + cumulativeAmount: 5_000n, + expiresAt: 0n, + }), + signatureBase58: voucher.signature, + signerBase58: payer.address, + }), + ).resolves.toBe(true); + }); + + it("tracks cumulative charges and carries the full channelConfig", async () => { + const tracker = new BatchChannelTracker(channelId, channelConfig, payer); + expect((await tracker.voucher(1_000n)).maxClaimableAmount).toBe("1000"); + expect((await tracker.voucher(1_500n)).maxClaimableAmount).toBe("2500"); + expect(tracker.channelConfig).toEqual(channelConfig); + }); + + /** + * Register a pending allocation the way a restarted client would, without + * touching an RPC. + */ + async function pendingClient(pending: { + amount: string; + cumulative: string; + deposit: string; + confirmed?: { cumulative: string; deposit: string }; + payment: Record; + }) { + const records = new Map(); + const storage: BatchClientChannelStorage = { + delete: async key => { + records.delete(key); + }, + get: async key => records.get(key), + set: async (key, record) => { + records.set(key, record); + }, + }; + const key = "pending-allocation"; + await storage.set(key, { + channelConfig, + channelId, + chargedCumulativeAmount: pending.confirmed?.cumulative ?? "0", + deposit: pending.confirmed?.deposit ?? "0", + hasConfirmedState: pending.confirmed !== undefined, + pending: { + amount: pending.amount, + chargedCumulativeAmount: pending.cumulative, + deposit: pending.deposit, + payment: pending.payment as never, + }, + }); + const client = new BatchClientScheme(payer, { channelStorage: storage }); + await ( + client as unknown as { loadChannel(channelKey: string): Promise } + ).loadChannel(key); + return { client, key, storage }; + } + + function voucherPayment(voucher: BatchVoucher) { + return { + payload: { channelConfig, type: "voucher" as const, voucher }, + x402Version: 2, + }; + } + + it("adopts a corrective cumulative base against its own signature", async () => { + const stale = await signedVoucher(1_000n); + const { client, key, storage } = await pendingClient({ + amount: "1000", + cumulative: "1000", + deposit: "10000", + payment: voucherPayment(stale), + }); + // The server holds this client's own signature at 3000. + const held = await signedVoucher(3_000n); + const corrective = requirements(); + corrective.extra = { + ...corrective.extra, + channelState: { + balance: "10000", + channelId, + chargedCumulativeAmount: "3000", + totalClaimed: "2000", + withdrawRequestedAt: 0, + }, + voucherState: { expiresAt: 0, signature: held.signature, signedMaxClaimable: "3000" }, + }; + + const result = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...voucherPayment(stale) }, + paymentRequired: { + accepts: [requirements({ network: "solana:other" }), corrective], + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + x402Version: 2, + }, + requirements: requirements(), + settleResponse: { success: false }, + } as Parameters>[0]); + + // The transport retries, now signing from the base the server proved. + expect(result).toMatchObject({ recovered: true }); + expect(await storage.get(key)).toMatchObject({ + chargedCumulativeAmount: "3000", + deposit: "10000", + }); + }); + + it("refuses a corrective base it did not sign", async () => { + const stale = await signedVoucher(1_000n); + const stranger = await generateKeyPairSigner(); + const forged = await signBatchVoucher(stranger, { + channelId, + expiresAt: 0, + maxClaimableAmount: 3_000n, + }); + const cases: { what: string; voucherState: unknown; charged: string }[] = [ + // Signed by a key that is not this client's authorizer. + { + charged: "3000", + voucherState: { expiresAt: 0, signature: forged.signature, signedMaxClaimable: "3000" }, + what: "a foreign signature", + }, + // A proof for less than the server claims to have charged. + { + charged: "4000", + voucherState: { + expiresAt: 0, + signature: (await signedVoucher(3_000n)).signature, + signedMaxClaimable: "3000", + }, + what: "a base above the proof", + }, + // No proof at all, and a base the chain does not corroborate. + { charged: "3000", voucherState: undefined, what: "an unproven base" }, + ]; + + for (const { what, voucherState, charged } of cases) { + const { client, key, storage } = await pendingClient({ + amount: "1000", + confirmed: { cumulative: "1000", deposit: "10000" }, + cumulative: "2000", + deposit: "10000", + payment: voucherPayment(stale), + }); + const corrective = requirements(); + corrective.extra = { + ...corrective.extra, + channelState: { + balance: "10000", + channelId, + chargedCumulativeAmount: charged, + totalClaimed: "2000", + withdrawRequestedAt: 0, + }, + ...(voucherState ? { voucherState } : {}), + }; + const result = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...voucherPayment(stale) }, + paymentRequired: { + accepts: [corrective], + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + x402Version: 2, + }, + requirements: requirements(), + settleResponse: { success: false }, + } as Parameters>[0]); + expect(result, what).toBeUndefined(); + // The confirmed base is restored, never the server's claim. + expect(await storage.get(key), what).toMatchObject({ + chargedCumulativeAmount: "1000", + }); + } + }); + + it("adopts an unproven base only when the chain corroborates it", async () => { + const stale = await signedVoucher(1_000n); + const { client, key, storage } = await pendingClient({ + amount: "1000", + cumulative: "1000", + deposit: "10000", + payment: voucherPayment(stale), + }); + const corrective = requirements(); + // A server that rebuilt its record from chain holds no voucher, so the + // base it reports is the settled watermark itself. + corrective.extra = { + ...corrective.extra, + channelState: { + balance: "10000", + channelId, + chargedCumulativeAmount: "2000", + totalClaimed: "2000", + withdrawRequestedAt: 0, + }, + }; + const result = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...voucherPayment(stale) }, + paymentRequired: { + accepts: [corrective], + error: BatchError.CUMULATIVE_AMOUNT_MISMATCH, + x402Version: 2, + }, + requirements: requirements(), + settleResponse: { success: false }, + } as Parameters>[0]); + expect(result).toMatchObject({ recovered: true }); + expect(await storage.get(key)).toMatchObject({ chargedCumulativeAmount: "2000" }); + }); + + it("does not take the server's word for what it charged or holds", async () => { + const voucher = await signedVoucher(1_000n); + const deposit = { + payload: { + channelConfig, + deposit: { amount: "10000", transaction: "open-transaction" }, + type: "deposit" as const, + voucher, + }, + x402Version: 2, + }; + const respond = async ( + extra: Record, + ): Promise<{ result: unknown; stored: unknown }> => { + const { client, key, storage } = await pendingClient({ + amount: "1000", + cumulative: "1000", + deposit: "10000", + payment: deposit, + }); + const result = await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { accepted: requirements(), ...deposit }, + requirements: requirements(), + settleResponse: { extra, success: true }, + } as Parameters>[0]); + return { result, stored: await storage.get(key) }; + }; + + // A charge above the advertised price is refused outright. + await expect( + respond({ + chargedAmount: "1001", + channelState: { balance: "10000", chargedCumulativeAmount: "1000" }, + commitmentId: `${channelId}:1000`, + }), + ).rejects.toThrow(/unexpected amount/); + + // A cumulative the client cannot derive leaves local state alone rather + // than adopting the server's accounting. + expect( + ( + await respond({ + chargedAmount: "1000", + channelState: { balance: "10000", chargedCumulativeAmount: "9999" }, + commitmentId: `${channelId}:1000`, + }) + ).stored, + ).toBeUndefined(); + + // The escrow is the deposit this client signed, not the balance the + // server reports — here inflated tenfold. + expect( + ( + await respond({ + chargedAmount: "1000", + channelState: { balance: "100000", chargedCumulativeAmount: "1000" }, + commitmentId: `${channelId}:1000`, + }) + ).stored, + ).toMatchObject({ chargedCumulativeAmount: "1000", deposit: "10000" }); + }); + + it("discards a recovered pending open when settlement fails", async () => { + const records = new Map(); + const storage: BatchClientChannelStorage = { + delete: async key => { + records.delete(key); + }, + get: async key => records.get(key), + set: async (key, record) => { + records.set(key, record); + }, + }; + const key = "recovered-pending-open"; + const voucher = await signedVoucher(1_000n); + await storage.set(key, { + channelConfig, + channelId, + chargedCumulativeAmount: "0", + deposit: "0", + hasConfirmedState: false, + pending: { + amount: "1000", + chargedCumulativeAmount: "1000", + deposit: "10000", + payment: { + payload: { + channelConfig, + deposit: { amount: "10000", transaction: "open-transaction" }, + type: "deposit", + voucher, + }, + x402Version: 2, + }, + }, + }); + + const client = new BatchClientScheme(payer, { channelStorage: storage }); + const recovered = await ( + client as unknown as { loadChannel(channelKey: string): Promise } + ).loadChannel(key); + expect(recovered).toBeUndefined(); + + await client.schemeHooks.onPaymentResponse!({ + paymentPayload: { + accepted: requirements(), + payload: { + channelConfig, + deposit: { amount: "10000", transaction: "open-transaction" }, + type: "deposit", + voucher, + }, + x402Version: 2, + }, + requirements: requirements(), + settleResponse: { success: false }, + } as Parameters>[0]); + + expect(await storage.get(key)).toBeUndefined(); + const restarted = new BatchClientScheme(payer, { channelStorage: storage }); + await expect( + (restarted as unknown as { loadChannel(channelKey: string): Promise }).loadChannel( + key, + ), + ).resolves.toBeUndefined(); + }); + + it("builds an open that binds the sponsor as payee/rent payer and receiver at 100%", async () => { + const built = await buildDepositPayload({ + blockhash: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + memo: "invoice-42", + mint: MINT, + openSlot: OPEN_SLOT, + payer, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }); + const open = await verifyOpenTransaction(built.payload.deposit.transaction, { + authorizedSigner: payer.address, + expectedBindingMemo: encodeReceiverBindingMemo(receiverAuthorizer.address), + feePayer: feePayer.address, + from: payer.address, + maxCap: 10_000n, + memo: "invoice-42", + mint: MINT, + openSlot: OPEN_SLOT, + payee: feePayer.address, + recipients: [{ bps: 10_000, recipient: RECEIVER }], + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: WITHDRAW_DELAY, + }); + expect(open.channelId).toBe(built.channelId); + expect(built.payload.channelConfig).toMatchObject({ + openSlot: Number(OPEN_SLOT), + payer: payer.address, + payerAuthorizer: payer.address, + receiver: RECEIVER, + token: MINT, + withdrawDelay: WITHDRAW_DELAY, + }); + expect(built.payload.voucher!.maxClaimableAmount).toBe("1000"); + }); + + it("builds an operator channel with a reusable payer proof", async () => { + const operator = await generateKeyPairSigner(); + const authorizationExpiresAt = Math.floor(Date.now() / 1000) + 3_600; + const built = await buildDepositPayload({ + authorizationExpiresAt, + blockhash: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: MINT, + openSlot: OPEN_SLOT, + operator: operator.address, + payer, + receiver: RECEIVER, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + voucherSigner: "server", + withdrawDelay: WITHDRAW_DELAY, + }); + expect(built.payload).toMatchObject({ + channelConfig: { + payerAuthorizer: operator.address, + voucherSigner: "server", + }, + }); + expect("maxClaimableAmount" in built.payload).toBe(false); + expect(built.payload.voucher).toBeUndefined(); + expect(built.payload.authorization!.requestId).toBeTruthy(); + expect(await verifyBatchAuthorization(built.payload.authorization!, operator.address)).toBe( + true, + ); + const stranger = await generateKeyPairSigner(); + expect(await verifyBatchAuthorization(built.payload.authorization!, stranger.address)).toBe( + false, + ); + expect( + encodeBatchAuthorizationMessage({ + channelId: built.channelId, + requestId: built.payload.authorization!.requestId, + authorizedAmount: 1_000n, + expiresAt: authorizationExpiresAt, + operator: operator.address, + payer: payer.address, + }), + ).toHaveLength(177); + expect( + await signBatchAuthorization( + payer, + built.channelId, + operator.address, + built.payload.authorization!.requestId, + 1_000n, + authorizationExpiresAt, + ), + ).toEqual(built.payload.authorization); + }); + + it("builds and verifies the payer-signed forced-close transaction", async () => { + const payload = await buildRefundPayload({ + blockhash: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, + channelConfig, + channelId, + feePayer: feePayer.address, + memo: "invoice-42", + payer, + voucher: await signedVoucher(1_000n), + }); + await expect( + verifyRequestCloseTransaction(payload.transaction, { + channelId, + feePayer: feePayer.address, + memo: "invoice-42", + payer: payer.address, + }), + ).resolves.toBeUndefined(); + await expect( + verifyRequestCloseTransaction(payload.transaction, { + channelId: RECEIVER, + feePayer: feePayer.address, + payer: payer.address, + }), + ).rejects.toThrow("account binding mismatch"); + }); + }); + + describe("onchain builders", () => { + it("builds settle and seal with canonical discriminators", async () => { + const voucher = await signedVoucher(1_000n); + const settle = buildSettleInstructions({ + channelId, + voucher: { + authorizedSigner: payer.address, + cumulativeAmount: 1_000n, + expiresAt: 0n, + signatureBase58: voucher.signature, + }, + }); + expect(settle).toHaveLength(2); + expect(settle[1]?.data[0]).toBe(SETTLE_DISCRIMINATOR); + expect(buildSealInstruction(channelId).data[0]).toBe(SEAL_DISCRIMINATOR); + }); + }); + + describe("facilitator registration surface", () => { + it("advertises one managed fee payer without a paymentFlow override", () => { + const facilitator = new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + expect(facilitator.getExtra(SOLANA_DEVNET_CAIP2)).toEqual({ + feePayer: feePayer.address, + maxIdleSecs: 604_800, + }); + expect(facilitator.getSigners(SOLANA_DEVNET_CAIP2)).toEqual([feePayer.address]); + }); + + it("advertises the configured idle window and omits a disabled one", () => { + const tuned = new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + maxIdleSecs: 3_600, + }); + expect(tuned.getExtra(SOLANA_DEVNET_CAIP2)).toMatchObject({ maxIdleSecs: 3_600 }); + const disabled = new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + maxIdleSecs: 0, + }); + expect(disabled.getExtra(SOLANA_DEVNET_CAIP2)).toEqual({ feePayer: feePayer.address }); + expect( + () => + new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + maxIdleSecs: -1, + }), + ).toThrow(/maxIdleSecs/); + }); + + it("copies the facilitator's idle window into the challenge", async () => { + const server = new BatchServerScheme({ receiverAuthorizer, store: new MemoryChannelStore() }); + const enhanced = await server.enhancePaymentRequirements( + { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: {}, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: feePayer.address, + scheme: "batch-settlement", + }, + { + extra: { feePayer: feePayer.address, maxIdleSecs: 604_800 }, + network: SOLANA_DEVNET_CAIP2, + scheme: "batch-settlement", + x402Version: 2, + }, + [], + ); + expect(enhanced.extra).toMatchObject({ feePayer: feePayer.address, maxIdleSecs: 604_800 }); + }); + + it("rejects vouchers with a nonzero expiry", async () => { + const server = new BatchServerScheme({ receiverAuthorizer, store: new MemoryChannelStore() }); + const expiring = await signedVoucher(1_000n, Math.floor(Date.now() / 1000) + 86_400); + const result = await server.schemeHooks.onBeforeVerify!({ + declaredExtensions: {}, + paymentPayload: { + accepted: requirements(), + payload: { channelConfig, type: "voucher", voucher: expiring }, + x402Version: 2, + }, + requirements: requirements(), + }); + expect(result).toMatchObject({ abort: true, reason: BatchError.VOUCHER_EXPIRY }); + }); + + it("rejects legacy payload shapes before touching RPC", async () => { + const facilitator = new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const result = await facilitator.verify( + { + accepted: requirements(), + payload: { channelId, type: "voucher", voucher: await signedVoucher(1_000n) }, + x402Version: 2, + }, + requirements(), + ); + expect(result).toMatchObject({ + invalidReason: BatchError.PAYLOAD_TYPE, + isValid: false, + }); + }); + + it("asks for a request_close, then validates it, when no binding is stored", async () => { + const facilitator = new BatchFacilitatorScheme(batchFacilitatorSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + (facilitator as unknown as { readChannel(): Promise }).readChannel = async () => + undefined; + const refund = { + channelConfig, + type: "refund" as const, + voucher: await signedVoucher(1_000n), + }; + const verify = (payload: typeof refund & { transaction?: string }) => + facilitator.verify({ accepted: requirements(), payload, x402Version: 2 }, requirements()); + await expect(verify(refund)).resolves.toMatchObject({ + invalidReason: BatchError.RECEIVER_BINDING_UNAVAILABLE, + isValid: false, + }); + await expect(verify({ ...refund, transaction: "not-a-transaction" })).resolves.toMatchObject({ + invalidReason: BatchError.REFUND_TRANSACTION, + isValid: false, + }); + }); + + it("rejects a refund that names an amount: only the full unused escrow returns", async () => { + const facilitator = new BatchFacilitatorScheme(toFacilitatorSvmSigner(feePayer), { + receiverAuthorizerStore: new InMemoryBatchReceiverAuthorizerStore(), + }); + const result = await facilitator.verify( + { + accepted: requirements(), + payload: { + amount: "500", + channelConfig, + type: "refund", + voucher: await signedVoucher(1_000n), + } as never, + x402Version: 2, + }, + requirements(), + ); + expect(result).toMatchObject({ + invalidReason: BatchError.CLOSE_AMOUNT_UNSUPPORTED, + isValid: false, + }); + }); + + it("sums only undistributed settled amounts", () => { + expect( + calculateDistributionAmount([ + { payoutWatermark: 1_000n, settled: 3_000n }, + { payoutWatermark: 500n, settled: 4_000n }, + ]), + ).toBe(5_500n); + expect(() => calculateDistributionAmount([{ payoutWatermark: 2n, settled: 1n }])).toThrow( + BatchError.CHANNEL_STATE, + ); + }); + }); + + it("request-close transaction keeps the sponsor signature slot empty", async () => { + const transaction = await buildRequestCloseTransaction({ + blockhash: { blockhash: DUMMY_BLOCKHASH, lastValidBlockHeight: 1n }, + channelId, + feePayer: feePayer.address, + payer, + }); + const decoded = getTransactionDecoder().decode(getBase64Codec().encode(transaction)); + expect(decoded.signatures[payer.address]).not.toBeNull(); + expect(decoded.signatures[feePayer.address]).toBeNull(); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/batch.voucherMode.test.ts b/typescript/packages/mechanisms/svm/test/unit/batch.voucherMode.test.ts new file mode 100644 index 0000000000..ebbe58cf59 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/batch.voucherMode.test.ts @@ -0,0 +1,223 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import type { PaymentRequirements } from "@x402/core/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + encodeBatchAuthorizationMessage, + signBatchAuthorization, +} from "../../src/batch-settlement/authorization"; +import { buildDepositPayload } from "../../src/batch-settlement/client/channel"; +import { BatchError } from "../../src/batch-settlement/errors"; +import { + assertServerModeProof, + assertServerModeRefundProof, + voucherSignerFor, +} from "../../src/batch-settlement/facilitator/voucherMode"; +import type { BatchChannelConfig, BatchRefundPayload } from "../../src/batch-settlement/types"; +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +let payer: Awaited>; +let operator: Awaited>; +let feePayer: Awaited>; +let receiverAuthorizer: Awaited>; +let channelId: string; +let clientConfig: BatchChannelConfig; +let serverConfig: BatchChannelConfig; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + operator = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + receiverAuthorizer = await generateKeyPairSigner(); + const built = await buildDepositPayload({ + blockhash: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + depositAmount: 10_000n, + feePayer: feePayer.address, + firstCharge: 1_000n, + mint: USDC_DEVNET_ADDRESS, + openSlot: 77n, + payer, + receiver: USDC_MAINNET_ADDRESS, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + }); + channelId = built.channelId; + clientConfig = built.payload.channelConfig; + serverConfig = { + ...clientConfig, + payerAuthorizer: operator.address, + voucherSigner: "server", + }; +}); + +function requirements(extra: Record = {}): PaymentRequirements { + return { + amount: "1000", + asset: USDC_DEVNET_ADDRESS, + extra: { + feePayer: feePayer.address, + receiverAuthorizer: receiverAuthorizer.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + withdrawDelay: 900, + ...extra, + }, + maxTimeoutSeconds: 300, + network: SOLANA_DEVNET_CAIP2, + payTo: USDC_MAINNET_ADDRESS, + scheme: "batch-settlement", + }; +} + +describe("batch facilitator voucher mode", () => { + it("follows channel config when terms are payload-bound", () => { + expect(voucherSignerFor(clientConfig, {}, "payload")).toBe("client"); + expect(voucherSignerFor(serverConfig, { operator: operator.address }, "payload")).toBe( + "server", + ); + expect(() => voucherSignerFor(serverConfig, {}, "payload")).toThrow(BatchError.CHANNEL_STATE); + expect(() => + voucherSignerFor({ ...clientConfig, voucherSigner: "bogus" as "client" }, {}, "payload"), + ).toThrow(BatchError.CHANNEL_STATE); + }); + + it("matches requirements extra to channel config when requirements-bound", () => { + expect(voucherSignerFor(clientConfig, { voucherSigner: "client" }, "requirements")).toBe( + "client", + ); + expect( + voucherSignerFor( + serverConfig, + { operator: operator.address, voucherSigner: "server" }, + "requirements", + ), + ).toBe("server"); + expect(() => + voucherSignerFor( + serverConfig, + { operator: feePayer.address, voucherSigner: "server" }, + "requirements", + ), + ).toThrow(BatchError.CHANNEL_STATE); + expect(() => + voucherSignerFor( + clientConfig, + { operator: operator.address, voucherSigner: "client" }, + "requirements", + ), + ).toThrow(BatchError.CHANNEL_STATE); + expect(() => + voucherSignerFor( + serverConfig, + { operator: operator.address, voucherSigner: "client" }, + "requirements", + ), + ).toThrow(BatchError.CHANNEL_STATE); + expect(() => + voucherSignerFor(clientConfig, { voucherSigner: "bogus" }, "requirements"), + ).toThrow(BatchError.CHANNEL_STATE); + }); + + it("accepts a valid server-mode payer proof at verify and settle ceilings", async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 600; + const authorization = await signBatchAuthorization( + payer, + channelId, + operator.address, + "request-1", + 1_000n, + expiresAt, + ); + const deposit = { + authorization, + channelConfig: serverConfig, + deposit: { amount: "10000", transaction: "setup" }, + type: "deposit" as const, + }; + const req = requirements({ operator: operator.address, voucherSigner: "server" }); + + await expect(assertServerModeProof(deposit, channelId, req, "exact")).resolves.toBeUndefined(); + await expect( + assertServerModeProof(deposit, channelId, { ...req, amount: "500" }, "ceiling"), + ).resolves.toBeUndefined(); + await expect( + assertServerModeProof(deposit, channelId, { ...req, amount: "500" }, "exact"), + ).rejects.toThrow(/invalid payer proof/); + await expect( + assertServerModeProof( + { ...deposit, authorization: { ...authorization, requestId: "" } }, + channelId, + req, + ), + ).rejects.toThrow(/invalid payer proof/); + await expect( + assertServerModeProof({ ...deposit, authorization: undefined }, channelId, req), + ).rejects.toThrow(/payer proof missing/); + }); + + it("accepts only a zero-amount authorization for server-mode cooperative refund", async () => { + const expiresAt = Math.floor(Date.now() / 1000) + 600; + const closeProof = await signBatchAuthorization( + payer, + channelId, + operator.address, + "close-1", + 0n, + expiresAt, + ); + const refund: BatchRefundPayload = { + authorization: closeProof, + channelConfig: serverConfig, + type: "refund", + }; + await expect(assertServerModeRefundProof(refund, channelId)).resolves.toBeUndefined(); + + const withVoucher = { + ...refund, + voucher: { + channelId, + expiresAt: 0, + maxClaimableAmount: "0", + signature: "sig", + }, + }; + await expect(assertServerModeRefundProof(withVoucher, channelId)).rejects.toThrow( + /invalid payer proof/, + ); + const charged = await signBatchAuthorization( + payer, + channelId, + operator.address, + "close-2", + 1n, + expiresAt, + ); + await expect( + assertServerModeRefundProof({ ...refund, authorization: charged }, channelId), + ).rejects.toThrow(/invalid payer proof/); + await expect( + assertServerModeRefundProof({ ...refund, authorization: undefined }, channelId), + ).rejects.toThrow(/payer proof missing/); + }); + + it("rejects malformed batch authorization message inputs", () => { + const base = { + channelId, + requestId: "request", + authorizedAmount: 1_000n, + expiresAt: Math.floor(Date.now() / 1000) + 60, + operator: operator.address, + payer: payer.address, + }; + expect(() => encodeBatchAuthorizationMessage({ ...base, expiresAt: 0 })).toThrow( + /positive safe integer/, + ); + expect(() => encodeBatchAuthorizationMessage({ ...base, requestId: "" })).toThrow( + /1 through 256 bytes/, + ); + expect(() => encodeBatchAuthorizationMessage({ ...base, authorizedAmount: -1n })).toThrow( + /u64/, + ); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/discovery.payer.test.ts b/typescript/packages/mechanisms/svm/test/unit/discovery.payer.test.ts new file mode 100644 index 0000000000..6644c05eb4 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/discovery.payer.test.ts @@ -0,0 +1,108 @@ +import { generateKeyPairSigner, getBase64Decoder, type Address } from "@solana/kit"; +import { describe, expect, it } from "vitest"; + +import { + CHANNEL_PAYER_OFFSET, + discoverChannelsByPayer, + type ProgramAccountScan, +} from "../../src/payment-channels/discovery"; +import { getChannelEncoder } from "../../src/payment-channels/generated/accounts/channel"; +import { AccountDiscriminator } from "../../src/payment-channels/generated/types/accountDiscriminator"; +import { ChannelStatus } from "../../src/payment-channels/generated/types/channelStatus"; +import { findPaymentChannelPda } from "../../src/payment-channels/open"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../src/payment-channels/onchain"; +import { USDC_DEVNET_ADDRESS } from "../../src/defaultAssets"; + +describe("payment-channel discovery by payer", () => { + it("keeps only rows whose PDA rederives from their own fields", async () => { + const payer = await generateKeyPairSigner(); + const payee = await generateKeyPairSigner(); + const salt = 0n; + const openSlot = 500n; + const channelId = await findPaymentChannelPda({ + authorizedSigner: payer.address, + mint: USDC_DEVNET_ADDRESS, + openSlot, + payee: payee.address, + payer: payer.address, + salt, + }); + const encode = (overrides: Record = {}) => + getBase64Decoder().decode( + getChannelEncoder().encode({ + authorizedSigner: payer.address as Address, + bump: 0, + closureStartedAt: 0n, + deposit: 10_000n, + discriminator: AccountDiscriminator.Channel, + distributionHash: new Array(32).fill(0), + gracePeriod: 900, + mint: USDC_DEVNET_ADDRESS as Address, + openSlot, + payee: payee.address as Address, + payer: payer.address as Address, + payerWithdrawnAt: 0n, + rentPayer: payee.address as Address, + salt, + settlement: { payoutWatermark: 0n, settled: 2_000n }, + status: ChannelStatus.Open, + version: 1, + ...overrides, + } as never), + ); + + const scan = + (rows: { pubkey: string; data: string; owner: string }[]): ProgramAccountScan => + async (_programId, filters) => { + // The filter must select on the payer offset, or the scan would return + // every channel the program owns. + expect(filters).toMatchObject([ + {}, + { memcmp: { bytes: payer.address, offset: CHANNEL_PAYER_OFFSET } }, + ]); + return rows.map(row => ({ + account: { data: [row.data, "base64"] as [string, string], owner: row.owner as Address }, + pubkey: row.pubkey as Address, + })); + }; + + // A genuine channel account. + await expect( + discoverChannelsByPayer( + scan([{ data: encode(), owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: channelId }]), + payer.address, + ), + ).resolves.toMatchObject([{ channelId, channel: { deposit: 10_000n } }]); + + // Same bytes at another address: the PDA no longer rederives, so a crafted + // account cannot pass itself off as a channel. + expect( + await discoverChannelsByPayer( + scan([{ data: encode(), owner: PAYMENT_CHANNELS_PROGRAM_ID, pubkey: payee.address }]), + payer.address, + ), + ).toEqual([]); + + // Owned by a different program. + expect( + await discoverChannelsByPayer( + scan([{ data: encode(), owner: payee.address, pubkey: channelId }]), + payer.address, + ), + ).toEqual([]); + + // Right address, but the seeds inside disagree with it. + expect( + await discoverChannelsByPayer( + scan([ + { + data: encode({ openSlot: 501n }), + owner: PAYMENT_CHANNELS_PROGRAM_ID, + pubkey: channelId, + }, + ]), + payer.address, + ), + ).toEqual([]); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/index.test.ts b/typescript/packages/mechanisms/svm/test/unit/index.test.ts index 4ca2da09b0..46738e313c 100644 --- a/typescript/packages/mechanisms/svm/test/unit/index.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/index.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect } from "vitest"; import { ExactSvmScheme, - assertSmartWalletLimits, validateSvmAddress, normalizeNetwork, getUsdcAddress, @@ -20,7 +19,7 @@ import { } from "../../src/index"; import type { FacilitatorSvmSigner } from "../../src/signer"; import { UptoSvmScheme } from "../../src/upto/facilitator/scheme"; -import { UptoSvmRentCleanupManager } from "../../src/upto/facilitator/rentCleanupManager"; +import { PaymentChannelRentCleanupManager } from "../../src/payment-channels/rentCleanup"; import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; import { ExactSvmScheme as ServerExactSvmScheme } from "../../src/exact/server/scheme"; import * as exactClientEntry from "../../src/exact/client"; @@ -47,7 +46,6 @@ describe("@x402/svm", () => { expect(ExactSvmScheme).toBeDefined(); expect(ExactSvmScheme).toBeDefined(); expect(ExactSvmScheme).toBeDefined(); - expect(assertSmartWalletLimits).toBeDefined(); }); it("re-exports register/scheme entry points callers import from package subpaths", () => { @@ -205,11 +203,6 @@ describe("@x402/svm", () => { confirmTransaction: async () => {}, }; - const uptoIncompleteSigner: FacilitatorSvmSigner = { - ...exactOnlySigner, - getSigner: () => ({ address: "11111111111111111111111111111111" as never }) as never, - }; - it("allows exact-only signers without getSigner", () => { expect(exactOnlySigner.getAddresses()).toHaveLength(1); expect(exactOnlySigner.getSigner).toBeUndefined(); @@ -219,17 +212,17 @@ describe("@x402/svm", () => { expect(() => new ExactSvmScheme(exactOnlySigner)).not.toThrow(); }); - it("accepts exact-only signers for UptoSvmScheme at compile time but rejects missing upto read RPC at runtime", () => { - expect(() => new UptoSvmScheme(uptoIncompleteSigner as never)).toThrow( - "UptoSvmScheme requires getAccountInfo on the signer", + it("accepts exact-only signers for UptoSvmScheme at compile time but rejects missing getSigner at runtime", () => { + expect(() => new UptoSvmScheme(exactOnlySigner)).toThrow( + "UptoSvmScheme requires getSigner on the signer", ); }); - it("rejects exact-only signers for UptoSvmRentCleanupManager at runtime", () => { + it("rejects exact-only signers for PaymentChannelRentCleanupManager at runtime", () => { expect( () => - new UptoSvmRentCleanupManager({ - signer: uptoIncompleteSigner as never, + new PaymentChannelRentCleanupManager({ + signer: exactOnlySigner, storage: { upsert: async () => {}, get: async () => undefined, @@ -238,7 +231,7 @@ describe("@x402/svm", () => { }, network: SOLANA_DEVNET_CAIP2, }), - ).toThrow("UptoSvmRentCleanupManager requires getAccountInfo on the signer"); + ).toThrow("PaymentChannelRentCleanupManager requires getSigner on the signer"); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.envelope.test.ts b/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.envelope.test.ts new file mode 100644 index 0000000000..5c52d5dd08 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.envelope.test.ts @@ -0,0 +1,284 @@ +import { + getSetComputeUnitLimitInstruction, + getSetComputeUnitPriceInstruction, +} from "@solana-program/compute-budget"; +import { + AccountRole, + type Address, + address, + appendTransactionMessageInstructions, + type Blockhash, + createTransactionMessage, + generateKeyPairSigner, + getBase64EncodedWireTransaction, + type Instruction, + partiallySignTransactionMessageWithSigners, + pipe, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + type TransactionSigner, +} from "@solana/kit"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { + COMPUTE_BUDGET_PROGRAM_ADDRESS, + LIGHTHOUSE_PROGRAM_ADDRESS, + MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS, + MAX_MEMO_BYTES, + MEMO_PROGRAM_ADDRESS, +} from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { verifyRequestCloseTransaction } from "../../src/payment-channels/close"; +import { + getRequestCloseInstruction, + REQUEST_CLOSE_DISCRIMINATOR, +} from "../../src/payment-channels/generated/instructions/requestClose"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../src/payment-channels/onchain"; +import { OPEN_MAX_COMPUTE_UNIT_LIMIT } from "../../src/payment-channels/open"; + +const BLOCKHASH = USDC_MAINNET_ADDRESS as Blockhash; +const CHANNEL = USDC_DEVNET_ADDRESS; +const PROGRAM = address(PAYMENT_CHANNELS_PROGRAM_ID); + +let payer: TransactionSigner; +let feePayer: TransactionSigner; +let bystander: TransactionSigner; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + bystander = await generateKeyPairSigner(); +}); + +/** Assemble and payer-sign an arbitrary instruction list behind `txFeePayer`. */ +async function wire(instructions: Instruction[], txFeePayer: string = feePayer.address) { + const message = pipe( + createTransactionMessage({ version: 0 }), + value => setTransactionMessageFeePayer(address(txFeePayer), value), + value => + setTransactionMessageLifetimeUsingBlockhash( + { blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, + value, + ), + value => appendTransactionMessageInstructions(instructions, value), + ); + return getBase64EncodedWireTransaction(await partiallySignTransactionMessageWithSigners(message)); +} + +function close(): Instruction { + return getRequestCloseInstruction( + { channel: address(CHANNEL), payer }, + { programAddress: PROGRAM }, + ) as Instruction; +} + +function memo(text = "0123456789abcdef0123456789abcdef"): Instruction { + return { + accounts: [], + data: new TextEncoder().encode(text), + programAddress: MEMO_PROGRAM_ADDRESS as Address, + }; +} + +function lighthouse(account: string = bystander.address): Instruction { + return { + accounts: [{ address: address(account), role: AccountRole.READONLY }], + data: new Uint8Array([1]), + programAddress: LIGHTHOUSE_PROGRAM_ADDRESS as Address, + }; +} + +function rawBudget(data: number[], accounts: Instruction["accounts"] = []): Instruction { + return { + accounts, + data: new Uint8Array(data), + programAddress: COMPUTE_BUDGET_PROGRAM_ADDRESS as Address, + }; +} + +function expected(overrides: Record = {}) { + return { channelId: CHANNEL, feePayer: feePayer.address, payer: payer.address, ...overrides }; +} + +describe("request-close envelope verification", () => { + it("accepts a capped Compute Budget prefix and a Lighthouse suffix", async () => { + const encoded = await wire([ + getSetComputeUnitLimitInstruction({ units: 10_000 }) as Instruction, + getSetComputeUnitPriceInstruction({ microLamports: 1_000 }) as Instruction, + close(), + lighthouse(), + memo(), + ]); + await expect(verifyRequestCloseTransaction(encoded, expected())).resolves.toBeUndefined(); + // Operator caps below the spec maxima still admit a compliant prefix. + await expect( + verifyRequestCloseTransaction( + encoded, + expected({ maxComputeUnits: 20_000, maxPriorityFeeMicroLamports: 2_000 }), + ), + ).resolves.toBeUndefined(); + }); + + it("rejects Compute Budget instructions outside the sponsor's policy", async () => { + const cases: [Instruction[], RegExp][] = [ + [ + [ + getSetComputeUnitLimitInstruction({ + units: OPEN_MAX_COMPUTE_UNIT_LIMIT + 1, + }) as Instruction, + ], + /compute unit limit exceeds sponsor cap/, + ], + [ + [ + getSetComputeUnitPriceInstruction({ + microLamports: MAX_COMPUTE_UNIT_PRICE_MICROLAMPORTS + 1, + }) as Instruction, + ], + /compute unit price exceeds sponsor cap/, + ], + [ + [ + getSetComputeUnitLimitInstruction({ units: 1 }) as Instruction, + getSetComputeUnitLimitInstruction({ units: 2 }) as Instruction, + ], + /invalid SetComputeUnitLimit/, + ], + [ + [ + getSetComputeUnitPriceInstruction({ microLamports: 1 }) as Instruction, + getSetComputeUnitLimitInstruction({ units: 1 }) as Instruction, + ], + /invalid SetComputeUnitLimit/, + ], + [ + [ + getSetComputeUnitPriceInstruction({ microLamports: 1 }) as Instruction, + getSetComputeUnitPriceInstruction({ microLamports: 2 }) as Instruction, + ], + /invalid SetComputeUnitPrice/, + ], + [[rawBudget([2, 1, 0])], /invalid SetComputeUnitLimit/], + [[rawBudget([3, 1, 0])], /invalid SetComputeUnitPrice/], + [[rawBudget([1, 0, 0, 0, 0])], /unsupported Compute Budget instruction/], + [[rawBudget([])], /invalid Compute Budget instruction/], + [ + [rawBudget([2, 16, 39, 0, 0], [{ address: address(CHANNEL), role: AccountRole.READONLY }])], + /invalid Compute Budget instruction/, + ], + ]; + for (const [prefix, message] of cases) { + await expect( + verifyRequestCloseTransaction(await wire([...prefix, close(), memo()]), expected()), + ).rejects.toThrow(message); + } + }); + + it("rejects a prefix-only transaction that never reaches request_close", async () => { + const encoded = await wire([getSetComputeUnitLimitInstruction({ units: 1 }) as Instruction]); + await expect(verifyRequestCloseTransaction(encoded, expected())).rejects.toThrow( + /required signers/, + ); + // With the payer forced into the signer set, the missing close is what fails. + const withPayer = await wire([ + getSetComputeUnitLimitInstruction({ units: 1 }) as Instruction, + { + accounts: [{ address: address(CHANNEL), role: AccountRole.READONLY }], + data: new Uint8Array([REQUEST_CLOSE_DISCRIMINATOR]), + programAddress: address(bystander.address), + }, + ]); + await expect(verifyRequestCloseTransaction(withPayer, expected())).rejects.toThrow( + /required signers/, + ); + }); + + it("rejects a suffix the sponsor does not allow", async () => { + const cases: [Instruction[], RegExp][] = [ + [[memo(), memo()], /invalid Memo suffix/], + [ + [ + { + accounts: [{ address: address(CHANNEL), role: AccountRole.READONLY }], + data: new TextEncoder().encode("0123456789abcdef0123456789abcdef"), + programAddress: MEMO_PROGRAM_ADDRESS as Address, + }, + ], + /invalid Memo suffix/, + ], + [[memo("x".repeat(MAX_MEMO_BYTES + 1))], /memo exceeds the byte limit/], + [[lighthouse(), lighthouse(), lighthouse(), lighthouse(), memo()], /too many Lighthouse/], + [[lighthouse(feePayer.address), memo()], /feePayer must not be a suffix account/], + [ + [ + { + accounts: [], + data: new Uint8Array([1]), + programAddress: address(bystander.address), + }, + memo(), + ], + /unsupported suffix instruction/, + ], + ]; + for (const [suffix, message] of cases) { + await expect( + verifyRequestCloseTransaction(await wire([close(), ...suffix]), expected()), + ).rejects.toThrow(message); + } + }); + + it("rejects a memo whose data is absent when a nonce is required", async () => { + const encoded = await wire([ + close(), + { accounts: [], programAddress: MEMO_PROGRAM_ADDRESS as Address }, + ]); + await expect(verifyRequestCloseTransaction(encoded, expected())).rejects.toThrow( + /nonce memo must be hexadecimal/, + ); + }); + + it("rejects a transaction whose fee payer slot is not the sponsor", async () => { + // Both required signers are present, but the payer sits in the fee-payer slot. + const encoded = await wire( + [ + close(), + { + accounts: [{ address: address(feePayer.address), role: AccountRole.READONLY_SIGNER }], + data: new Uint8Array([1]), + programAddress: LIGHTHOUSE_PROGRAM_ADDRESS as Address, + }, + memo(), + ], + payer.address, + ); + await expect(verifyRequestCloseTransaction(encoded, expected())).rejects.toThrow( + /transaction fee payer mismatch/, + ); + }); + + it("rejects a missing or forged payer signature", async () => { + const unsigned = await wire([ + { + accounts: [ + { address: address(payer.address), role: AccountRole.READONLY_SIGNER }, + { address: address(CHANNEL), role: AccountRole.WRITABLE }, + ], + data: new Uint8Array([REQUEST_CLOSE_DISCRIMINATOR]), + programAddress: PROGRAM, + }, + memo(), + ]); + await expect(verifyRequestCloseTransaction(unsigned, expected())).rejects.toThrow( + /missing payer signature/, + ); + + const signed = Buffer.from(await wire([close(), memo()]), "base64"); + // Wire layout: signature count, then one 64-byte signature per signer in + // static-account order (fee payer first, payer second). + signed[1 + 64 + 7] ^= 0xff; + await expect( + verifyRequestCloseTransaction(signed.toString("base64"), expected()), + ).rejects.toThrow(/invalid payer signature/); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.test.ts b/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.test.ts new file mode 100644 index 0000000000..3e4dfa4a98 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/payment-channels.close.test.ts @@ -0,0 +1,138 @@ +import { generateKeyPairSigner } from "@solana/kit"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { MAX_MEMO_BYTES } from "../../src/constants"; +import { + buildRequestCloseTransaction, + verifyRequestCloseTransaction, +} from "../../src/payment-channels/close"; +import { PAYMENT_CHANNELS_PROGRAM_ID } from "../../src/payment-channels/onchain"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; + +const BLOCKHASH = USDC_MAINNET_ADDRESS; +const CHANNEL = USDC_DEVNET_ADDRESS; + +let payer: Awaited>; +let feePayer: Awaited>; +let other: Awaited>; + +beforeAll(async () => { + payer = await generateKeyPairSigner(); + feePayer = await generateKeyPairSigner(); + other = await generateKeyPairSigner(); +}); + +async function transaction(memo?: string, programId?: string) { + return buildRequestCloseTransaction({ + blockhash: { blockhash: BLOCKHASH, lastValidBlockHeight: 1n }, + channelId: CHANNEL, + feePayer: feePayer.address, + memo, + payer, + programId, + }); +} + +describe("payment channel request-close boundaries", () => { + it("builds and verifies nonce and explicit memo variants", async () => { + await expect( + verifyRequestCloseTransaction(await transaction(), { + channelId: CHANNEL, + feePayer: feePayer.address, + payer: payer.address, + }), + ).resolves.toBeUndefined(); + await expect( + verifyRequestCloseTransaction(await transaction("invoice"), { + channelId: CHANNEL, + feePayer: feePayer.address, + memo: "invoice", + payer: payer.address, + }), + ).resolves.toBeUndefined(); + }); + + it("supports an explicitly selected payment-channel program", async () => { + await expect( + verifyRequestCloseTransaction(await transaction("custom", PAYMENT_CHANNELS_PROGRAM_ID), { + channelId: CHANNEL, + feePayer: feePayer.address, + memo: "custom", + payer: payer.address, + programId: PAYMENT_CHANNELS_PROGRAM_ID, + }), + ).resolves.toBeUndefined(); + }); + + it("rejects envelope and binding substitutions", async () => { + const encoded = await transaction("invoice"); + const cases = [ + { + expected: { + channelId: CHANNEL, + feePayer: payer.address, + memo: "invoice", + payer: payer.address, + }, + message: /payer must differ/, + }, + { + expected: { + channelId: CHANNEL, + feePayer: other.address, + memo: "invoice", + payer: payer.address, + }, + message: /required signers|fee payer mismatch/, + }, + { + expected: { + channelId: CHANNEL, + feePayer: feePayer.address, + memo: "invoice", + payer: other.address, + }, + message: /required signers/, + }, + { + expected: { + channelId: other.address, + feePayer: feePayer.address, + memo: "invoice", + payer: payer.address, + }, + message: /account binding mismatch/, + }, + { + expected: { + channelId: CHANNEL, + feePayer: feePayer.address, + memo: "other", + payer: payer.address, + }, + message: /memo mismatch/, + }, + { + expected: { channelId: CHANNEL, feePayer: feePayer.address, payer: payer.address }, + message: /nonce memo must be hexadecimal/, + }, + { + expected: { + channelId: CHANNEL, + feePayer: feePayer.address, + memo: "invoice", + payer: payer.address, + programId: other.address, + }, + message: /unexpected instruction before request_close/, + }, + ]; + for (const { expected, message } of cases) { + await expect(verifyRequestCloseTransaction(encoded, expected)).rejects.toThrow(message); + } + }); + + it("rejects a memo above the byte limit", async () => { + await expect(transaction("x".repeat(MAX_MEMO_BYTES + 1))).rejects.toThrow(/exceeds maximum/); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/payment-channels.facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/payment-channels.facilitator.test.ts new file mode 100644 index 0000000000..1d8c2c6968 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/payment-channels.facilitator.test.ts @@ -0,0 +1,364 @@ +import { address, generateKeyPairSigner, type Signature } from "@solana/kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import type { Channel } from "../../src/payment-channels/generated/accounts/channel"; +import type { ChannelSplit } from "../../src/payment-channels/open"; + +const channelAccountMocks = vi.hoisted(() => ({ + fetchMaybeChannel: vi.fn(), +})); + +vi.mock("../../src/payment-channels/generated/accounts/channel", async importOriginal => { + const actual = + await importOriginal(); + return { + ...actual, + fetchMaybeChannel: channelAccountMocks.fetchMaybeChannel, + }; +}); + +import { + broadcastOpen, + channelExists, + ChannelBroadcastConfirmationError, + ChannelSimulationError, + confirmSignature, + DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT, + fetchAndVerifyOpenChannel, + getChannelDistributionHash, + reclaimComputeUnitLimit, + resolveChannelReadPolicy, + SettlementConfirmationTimeoutError, + submitChannelTransactionWithSigner, + submitSettle, + verifyOpenChannelAccount, + type ChannelRpc, + type ExpectedOpenChannel, +} from "../../src/payment-channels/facilitator"; +import { MEMO_PROGRAM_ADDRESS, SOLANA_DEVNET_CAIP2 } from "../../src/constants"; + +const CHANNEL_ID = USDC_MAINNET_ADDRESS; +const rpc = {} as ChannelRpc; +const PAYEE = USDC_DEVNET_ADDRESS; +const PAYER = USDC_MAINNET_ADDRESS; +const AUTHORIZED_SIGNER = USDC_DEVNET_ADDRESS; +const MINT = USDC_MAINNET_ADDRESS; +const SPLITS: readonly ChannelSplit[] = [{ recipient: PAYEE, bps: 10_000 }]; + +const expected: ExpectedOpenChannel = { + authorizedSigner: AUTHORIZED_SIGNER, + deposit: 1_000_000n, + gracePeriod: 900, + mint: MINT, + payee: PAYEE, + payer: PAYER, + rentPayer: PAYEE, + splits: SPLITS, +}; + +const channel: Channel = { + discriminator: 1, + version: 1, + bump: 1, + status: 0, + salt: 1n, + deposit: expected.deposit, + settlement: { settled: 0n, payoutWatermark: 0n }, + closureStartedAt: 0n, + payerWithdrawnAt: 0n, + gracePeriod: expected.gracePeriod, + distributionHash: getChannelDistributionHash(SPLITS), + payer: address(PAYER), + payee: address(PAYEE), + authorizedSigner: address(AUTHORIZED_SIGNER), + mint: address(MINT), + rentPayer: address(PAYEE), + openSlot: 1n, +}; + +const missingAccount = { exists: false, address: address(CHANNEL_ID) }; +const existingAccount = { + exists: true, + address: address(CHANNEL_ID), + data: channel, + executable: false, + lamports: 2_000_000n, + programAddress: address(PAYEE), + space: 0n, +}; + +describe("upto SVM channel reads", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("retries a missing confirmed channel until it becomes visible", async () => { + vi.useFakeTimers(); + channelAccountMocks.fetchMaybeChannel + .mockResolvedValueOnce(missingAccount) + .mockResolvedValueOnce(existingAccount); + + const result = fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected); + await vi.advanceTimersByTimeAsync(200); + + await expect(result).resolves.toMatchObject({ channelId: CHANNEL_ID }); + expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(2); + }); + + it("stops after six missing reads", async () => { + vi.useFakeTimers(); + channelAccountMocks.fetchMaybeChannel.mockResolvedValue(missingAccount); + + const result = fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected); + const assertion = expect(result).rejects.toThrow(`channel ${CHANNEL_ID} does not exist`); + await vi.advanceTimersByTimeAsync(3_000); + + await assertion; + expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(6); + }); + + it("does not retry an existing channel with invalid state", async () => { + channelAccountMocks.fetchMaybeChannel.mockResolvedValue({ + ...existingAccount, + data: { ...channel, status: 1 }, + }); + + await expect(fetchAndVerifyOpenChannel(rpc, CHANNEL_ID, expected)).rejects.toThrow( + `channel ${CHANNEL_ID} is not open`, + ); + expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(1); + }); + + it("validates policy overrides and channel identity fields", () => { + expect(resolveChannelReadPolicy({ maxAttempts: 3, backoffStepMs: 7 })).toEqual({ + maxAttempts: 3, + backoffStepMs: 7, + }); + expect(() => + verifyOpenChannelAccount(CHANNEL_ID, { ...channel, discriminator: 9 }, expected), + ).toThrow(/invalid account discriminator/); + expect(() => + verifyOpenChannelAccount(CHANNEL_ID, { ...channel, payee: address(PAYER) }, expected), + ).toThrow(/channel payee/); + expect(() => + verifyOpenChannelAccount(CHANNEL_ID, { ...channel, mint: address(PAYEE) }, expected), + ).toThrow(/channel mint/); + expect(() => + verifyOpenChannelAccount(CHANNEL_ID, { ...channel, deposit: 2n }, expected), + ).toThrow(/channel deposit/); + }); + + it("checks channel account presence through the RPC", async () => { + const send = vi + .fn() + .mockResolvedValueOnce({ value: null }) + .mockResolvedValueOnce({ value: {} }); + const accountRpc = { getAccountInfo: vi.fn(() => ({ send })) } as never; + await expect(channelExists(accountRpc, CHANNEL_ID)).resolves.toBe(false); + await expect(channelExists(accountRpc, CHANNEL_ID)).resolves.toBe(true); + }); +}); + +describe("payment-channel transaction submission", () => { + const signature = USDC_DEVNET_ADDRESS as Signature; + const instruction = { + accounts: [], + data: new TextEncoder().encode("settle"), + programAddress: address(MEMO_PROGRAM_ADDRESS), + }; + + it("records signed bytes before RPC submission, including when the send response is lost", async () => { + const feePayer = await generateKeyPairSigner(); + let recorded: { signature: string; wire: string } | undefined; + const sendTransaction = vi.fn(async (wire: string) => { + expect(recorded?.wire).toBe(wire); + expect(recorded?.signature).toBeTruthy(); + throw new Error("accepted by RPC, response lost"); + }); + const submit = submitChannelTransactionWithSigner( + feePayer, + { + getLatestBlockhash: async () => ({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 100n, + }), + simulateTransaction: async () => undefined, + sendTransaction, + confirmTransaction: vi.fn(), + }, + SOLANA_DEVNET_CAIP2, + [instruction], + { + onPrepared: async (signature, wire) => { + recorded = { signature, wire }; + }, + }, + ); + await expect(submit).rejects.toBeInstanceOf(SettlementConfirmationTimeoutError); + expect(sendTransaction).toHaveBeenCalledOnce(); + }); + + it("does not broadcast if recording signed bytes fails", async () => { + const feePayer = await generateKeyPairSigner(); + const sendTransaction = vi.fn(); + await expect( + submitChannelTransactionWithSigner( + feePayer, + { + getLatestBlockhash: async () => ({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 100n, + }), + simulateTransaction: async () => undefined, + sendTransaction, + confirmTransaction: vi.fn(), + }, + SOLANA_DEVNET_CAIP2, + [instruction], + { + onPrepared: async () => { + throw new Error("storage unavailable"); + }, + }, + ), + ).rejects.toThrow("storage unavailable"); + expect(sendTransaction).not.toHaveBeenCalled(); + }); + + it("broadcasts an open and reports its signature before confirmation", async () => { + const events: string[] = []; + const facilitator = { + signTransaction: vi.fn().mockResolvedValue("signed"), + sendTransaction: vi.fn().mockResolvedValue(signature), + confirmTransaction: vi.fn().mockImplementation(async () => events.push("confirmed")), + }; + await expect( + broadcastOpen(facilitator, address(PAYEE), SOLANA_DEVNET_CAIP2, "open", async value => { + events.push(`broadcast:${value}`); + }), + ).resolves.toBe(signature); + expect(events).toEqual([`broadcast:${signature}`, "confirmed"]); + }); + + it("carries an unconfirmed open signature", async () => { + const facilitator = { + signTransaction: vi.fn().mockResolvedValue("signed"), + sendTransaction: vi.fn().mockResolvedValue(signature), + confirmTransaction: vi.fn().mockRejectedValue(new Error("timeout")), + }; + await expect( + broadcastOpen(facilitator, address(PAYEE), SOLANA_DEVNET_CAIP2, "open"), + ).rejects.toMatchObject({ name: "ChannelBroadcastConfirmationError", signature }); + expect(new ChannelBroadcastConfirmationError(signature, "timeout").cause).toBe("timeout"); + }); + + it("submits, simulates, and confirms through a facilitator signer", async () => { + const feePayer = await generateKeyPairSigner(); + const signer = { + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 1n, + }), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + sendTransaction: vi.fn().mockResolvedValue(signature), + confirmTransaction: vi.fn().mockResolvedValue(undefined), + }; + const onBroadcast = vi.fn().mockResolvedValue(undefined); + await expect( + submitChannelTransactionWithSigner(feePayer, signer, SOLANA_DEVNET_CAIP2, [instruction], { + computeUnitLimit: DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT, + onBroadcast, + }), + ).resolves.toBe(signature); + expect(signer.simulateTransaction).toHaveBeenCalledOnce(); + expect(onBroadcast).toHaveBeenCalledWith(signature); + expect(signer.confirmTransaction).toHaveBeenCalledWith(signature, SOLANA_DEVNET_CAIP2); + }); + + it("rejects missing blockhash support and simulation failures", async () => { + const feePayer = await generateKeyPairSigner(); + const withoutBlockhash = { + simulateTransaction: vi.fn(), + sendTransaction: vi.fn(), + confirmTransaction: vi.fn(), + }; + await expect( + submitChannelTransactionWithSigner(feePayer, withoutBlockhash, SOLANA_DEVNET_CAIP2, [ + instruction, + ]), + ).rejects.toThrow(/requires getLatestBlockhash/); + + const signer = { + ...withoutBlockhash, + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 1n, + }), + simulateTransaction: vi.fn().mockRejectedValue(new Error("bad simulation")), + }; + await expect( + submitChannelTransactionWithSigner(feePayer, signer, SOLANA_DEVNET_CAIP2, [instruction]), + ).rejects.toBeInstanceOf(ChannelSimulationError); + expect(signer.sendTransaction).not.toHaveBeenCalled(); + }); + + it("turns unknown confirmation failures into a durable timeout", async () => { + const feePayer = await generateKeyPairSigner(); + const signer = { + getLatestBlockhash: vi.fn().mockResolvedValue({ + blockhash: USDC_MAINNET_ADDRESS, + lastValidBlockHeight: 1n, + }), + simulateTransaction: vi.fn().mockResolvedValue(undefined), + sendTransaction: vi.fn().mockResolvedValue(signature), + confirmTransaction: vi.fn().mockRejectedValue(new Error("rpc timeout")), + }; + await expect( + submitChannelTransactionWithSigner(feePayer, signer, SOLANA_DEVNET_CAIP2, [instruction]), + ).rejects.toMatchObject({ name: "SettlementConfirmationTimeoutError", signature }); + }); + + it("submits through RPC and polls confirmed status", async () => { + const feePayer = await generateKeyPairSigner(); + const rpc = { + getLatestBlockhash: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ + value: { blockhash: USDC_MAINNET_ADDRESS, lastValidBlockHeight: 1n }, + }), + })), + sendTransaction: vi.fn(() => ({ send: vi.fn().mockResolvedValue(signature) })), + getSignatureStatuses: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ + value: [{ confirmationStatus: "confirmed", err: null }], + }), + })), + } as never; + await expect(submitSettle(feePayer, rpc, [instruction])).resolves.toBe(signature); + }); + + it("reports onchain errors and confirmation timeouts", async () => { + const failedRpc = { + getSignatureStatuses: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ value: [{ confirmationStatus: "confirmed", err: 7n }] }), + })), + } as never; + await expect(confirmSignature(failedRpc, signature, 0)).rejects.toThrow(/failed onchain/); + + const pendingRpc = { + getSignatureStatuses: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ value: [null] }), + })), + } as never; + await expect(confirmSignature(pendingRpc, signature, 0)).rejects.toBeInstanceOf( + SettlementConfirmationTimeoutError, + ); + }); + + it("sizes reclaim batches within the transaction ceiling", () => { + expect(reclaimComputeUnitLimit(1)).toBe(30_000); + expect(reclaimComputeUnitLimit(8)).toBe(65_000); + expect(reclaimComputeUnitLimit(1_000_000)).toBe(1_400_000); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/payment-channels.signer.history.test.ts b/typescript/packages/mechanisms/svm/test/unit/payment-channels.signer.history.test.ts new file mode 100644 index 0000000000..bcf72ec6ef --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/payment-channels.signer.history.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rpcMocks = vi.hoisted(() => ({ + getSignaturesForAddressSend: vi.fn(), + getTransactionSend: vi.fn(), +})); + +vi.mock("../../src/utils", async () => { + const actual = await vi.importActual("../../src/utils"); + return { + ...actual, + createRpcClient: vi.fn(() => ({ + getSignaturesForAddress: () => ({ send: rpcMocks.getSignaturesForAddressSend }), + getTransaction: () => ({ send: rpcMocks.getTransactionSend }), + })), + }; +}); + +import { + createReceiverBindingHistoryReader, + receiverBindingHistoryReaderFromSigner, +} from "../../src/batch-settlement/facilitator/receiverBindingHistoryReader"; +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; +import { USDC_DEVNET_ADDRESS, USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { channelHistoryReads } from "../../src/payment-channels/signer"; + +const CHANNEL = USDC_MAINNET_ADDRESS; +const SIGNATURE = USDC_DEVNET_ADDRESS; + +describe("channelHistoryReads", () => { + beforeEach(() => { + rpcMocks.getSignaturesForAddressSend.mockReset(); + rpcMocks.getTransactionSend.mockReset(); + }); + + it("pages signatures and reads base64 transaction bytes", async () => { + rpcMocks.getSignaturesForAddressSend.mockResolvedValue([{ err: null, signature: "sig-1" }]); + rpcMocks.getTransactionSend.mockResolvedValue({ + transaction: ["dGVzdA=="], + }); + + const reads = channelHistoryReads({ [SOLANA_DEVNET_CAIP2]: "https://history.example" }); + const page = await reads.getSignaturesForAddress(CHANNEL, SOLANA_DEVNET_CAIP2, { + before: SIGNATURE, + limit: 5, + }); + expect(page).toEqual([{ err: null, signature: "sig-1" }]); + + await expect(reads.getTransaction(SIGNATURE, SOLANA_DEVNET_CAIP2)).resolves.toBe("dGVzdA=="); + }); + + it("returns null when the transaction is missing or not base64 wire", async () => { + const reads = channelHistoryReads(); + rpcMocks.getTransactionSend.mockResolvedValueOnce(null); + await expect(reads.getTransaction(SIGNATURE, SOLANA_DEVNET_CAIP2)).resolves.toBeNull(); + + rpcMocks.getTransactionSend.mockResolvedValueOnce({ transaction: [123] }); + await expect( + reads.getTransaction(USDC_MAINNET_ADDRESS, SOLANA_DEVNET_CAIP2), + ).resolves.toBeNull(); + }); +}); + +describe("receiver binding history reader factories", () => { + beforeEach(() => { + rpcMocks.getSignaturesForAddressSend.mockReset(); + rpcMocks.getTransactionSend.mockReset(); + }); + + it("returns undefined when the signer lacks history reads", () => { + expect(receiverBindingHistoryReaderFromSigner({} as never)).toBeUndefined(); + expect( + receiverBindingHistoryReaderFromSigner({ getSignaturesForAddress: vi.fn() } as never), + ).toBeUndefined(); + }); + + it("adapts signer history reads", async () => { + const getSignaturesForAddress = vi.fn(async () => [{ err: null, signature: "s" }]); + const getTransaction = vi.fn(async () => "wire"); + const historyReader = receiverBindingHistoryReaderFromSigner({ + getSignaturesForAddress, + getTransaction, + } as never); + + expect(historyReader).toBeDefined(); + await expect( + historyReader!.getSignaturesForAddress(SOLANA_DEVNET_CAIP2, CHANNEL), + ).resolves.toEqual([{ err: null, signature: "s" }]); + expect(getSignaturesForAddress).toHaveBeenCalledWith(CHANNEL, SOLANA_DEVNET_CAIP2, undefined); + + await expect(historyReader!.getTransaction(SOLANA_DEVNET_CAIP2, "s")).resolves.toBe("wire"); + expect(getTransaction).toHaveBeenCalledWith("s", SOLANA_DEVNET_CAIP2); + }); + + it("createReceiverBindingHistoryReader reads through RPC history", async () => { + rpcMocks.getSignaturesForAddressSend.mockResolvedValue([]); + const historyReader = createReceiverBindingHistoryReader(); + await historyReader.getSignaturesForAddress(SOLANA_DEVNET_CAIP2, CHANNEL); + expect(rpcMocks.getSignaturesForAddressSend).toHaveBeenCalled(); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/payment-channels.storage.test.ts b/typescript/packages/mechanisms/svm/test/unit/payment-channels.storage.test.ts new file mode 100644 index 0000000000..b949cf88b1 --- /dev/null +++ b/typescript/packages/mechanisms/svm/test/unit/payment-channels.storage.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { SOLANA_DEVNET_CAIP2, TOKEN_PROGRAM_ADDRESS } from "../../src/constants"; +import { USDC_MAINNET_ADDRESS } from "../../src/defaultAssets"; +import { + InMemoryPaymentChannelStorage, + type PaymentChannelRecord, +} from "../../src/payment-channels/storage"; + +function record(overrides: Partial = {}): PaymentChannelRecord { + return { + channelId: "chan-a", + expiresAt: 0, + firstSeenAt: 10, + lastActivityAt: 10, + network: SOLANA_DEVNET_CAIP2, + payTo: USDC_MAINNET_ADDRESS, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + ...overrides, + }; +} + +describe("payment-channel facilitator storage", () => { + it("keeps the first sighting and the latest activity across upserts", async () => { + const storage = new InMemoryPaymentChannelStorage(); + await storage.upsert(record({ expiresAt: 50 })); + await storage.upsert(record({ expiresAt: 40, firstSeenAt: 20, lastActivityAt: 30 })); + await storage.upsert(record({ firstSeenAt: 25, lastActivityAt: 15 })); + expect(await storage.get("chan-a")).toMatchObject({ + expiresAt: 50, + firstSeenAt: 10, + lastActivityAt: 30, + }); + }); +}); diff --git a/typescript/packages/mechanisms/svm/test/unit/signer.test.ts b/typescript/packages/mechanisms/svm/test/unit/signer.test.ts index 208dd34cfb..dc80672614 100644 --- a/typescript/packages/mechanisms/svm/test/unit/signer.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/signer.test.ts @@ -27,6 +27,20 @@ import { SOLANA_DEVNET_CAIP2, SOLANA_MAINNET_CAIP2 } from "../../src/constants"; import { encodeSignedTransaction, placeholderFeePayerSignature } from "./helpers/signedTransaction"; describe("SVM Signer Converters", () => { + it("searches older history only when recovery requests it", async () => { + const getSignatureStatuses = vi.fn().mockReturnValue({ + send: async () => ({ value: [{ slot: 55n, confirmationStatus: "confirmed", err: null }] }), + }); + const caps = createRpcCapabilitiesFromRpc({ getSignatureStatuses } as never); + await caps.confirmTransaction("fresh"); + expect(getSignatureStatuses).toHaveBeenLastCalledWith(["fresh"]); + await caps.confirmTransaction("recover", { searchTransactionHistory: true }); + expect(getSignatureStatuses).toHaveBeenLastCalledWith(["recover"], { + searchTransactionHistory: true, + }); + expect(getSignatureStatuses).toHaveBeenCalledTimes(2); + }); + describe("toClientSvmSigner", () => { it("should return the same signer (identity function)", () => { const mockSigner: ClientSvmSigner = { @@ -292,7 +306,7 @@ describe("SVM Signer Converters", () => { await expect( facilitator.confirmTransaction("okSignature", SOLANA_DEVNET_CAIP2), - ).resolves.toBeUndefined(); + ).resolves.toMatchObject({ confirmationStatus: "finalized", err: null }); }); it("should simulate with sigVerify disabled", async () => { diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts index d9cf0cf7f1..ed3b96f4b9 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.channel.test.ts @@ -18,8 +18,12 @@ vi.mock("../../src/payment-channels/generated/accounts/channel", async importOri }; }); +import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; import { accountFetchRpc, + type PaymentChannelFacilitatorSigner, +} from "../../src/payment-channels/signer"; +import { channelExists, DEFAULT_CHANNEL_READ_BACKOFF_STEP_MS, DEFAULT_CHANNEL_READ_MAX_ATTEMPTS, @@ -28,12 +32,10 @@ import { getChannelDistributionHash, resolveChannelReadPolicy, type ExpectedOpenChannel, -} from "../../src/upto/facilitator/channel"; -import { SOLANA_DEVNET_CAIP2 } from "../../src/constants"; -import type { UptoFacilitatorSigner } from "../../src/upto/facilitator/signer"; +} from "../../src/payment-channels/facilitator"; const CHANNEL_ID = USDC_MAINNET_ADDRESS; -const signer = {} as UptoFacilitatorSigner; +const signer = {} as PaymentChannelFacilitatorSigner; const NETWORK = SOLANA_DEVNET_CAIP2; const PAYEE = USDC_DEVNET_ADDRESS; const PAYER = USDC_MAINNET_ADDRESS; @@ -124,7 +126,11 @@ describe("upto SVM channel reads", () => { .mockResolvedValueOnce(missingAccount) .mockResolvedValueOnce(existingAccount); - const result = fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected); + const result = fetchAndVerifyOpenChannel( + accountFetchRpc(signer, NETWORK), + CHANNEL_ID, + expected, + ); await vi.advanceTimersByTimeAsync(200); await expect(result).resolves.toMatchObject({ channelId: CHANNEL_ID }); @@ -135,7 +141,11 @@ describe("upto SVM channel reads", () => { vi.useFakeTimers(); channelAccountMocks.fetchMaybeChannel.mockResolvedValue(missingAccount); - const result = fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected); + const result = fetchAndVerifyOpenChannel( + accountFetchRpc(signer, NETWORK), + CHANNEL_ID, + expected, + ); const assertion = expect(result).rejects.toThrow(`channel ${CHANNEL_ID} does not exist`); await vi.advanceTimersByTimeAsync(3_000); @@ -148,10 +158,15 @@ describe("upto SVM channel reads", () => { vi.useFakeTimers(); channelAccountMocks.fetchMaybeChannel.mockResolvedValue(missingAccount); - const result = fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected, { - maxAttempts: 3, - backoffStepMs: 1, - }); + const result = fetchAndVerifyOpenChannel( + accountFetchRpc(signer, NETWORK), + CHANNEL_ID, + expected, + { + maxAttempts: 3, + backoffStepMs: 1, + }, + ); const assertion = expect(result).rejects.toThrow(`channel ${CHANNEL_ID} does not exist`); await vi.advanceTimersByTimeAsync(10); @@ -165,20 +180,17 @@ describe("upto SVM channel reads", () => { data: { ...channel, status: 1 }, }); - await expect(fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected)).rejects.toThrow( - `channel ${CHANNEL_ID} is not open`, - ); + await expect( + fetchAndVerifyOpenChannel(accountFetchRpc(signer, NETWORK), CHANNEL_ID, expected), + ).rejects.toThrow(`channel ${CHANNEL_ID} is not open`); expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(1); }); it("channelExists follows getAccountInfo presence", async () => { const getAccountInfo = vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce({ data: "x" }); - await expect(channelExists({ getAccountInfo } as never, NETWORK, CHANNEL_ID)).resolves.toBe( - false, - ); - await expect(channelExists({ getAccountInfo } as never, NETWORK, CHANNEL_ID)).resolves.toBe( - true, - ); + const rpc = accountFetchRpc({ getAccountInfo } as never, NETWORK); + await expect(channelExists(rpc, CHANNEL_ID)).resolves.toBe(false); + await expect(channelExists(rpc, CHANNEL_ID)).resolves.toBe(true); expect(getAccountInfo).toHaveBeenCalledWith(CHANNEL_ID, NETWORK, { commitment: "confirmed", encoding: "base64", @@ -204,30 +216,39 @@ describe("upto SVM channel reads", () => { data: { ...channel, discriminator: 99 }, }); - await expect(fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, expected)).rejects.toThrow( - `channel ${CHANNEL_ID} has an invalid account discriminator`, - ); + await expect( + fetchAndVerifyOpenChannel(accountFetchRpc(signer, NETWORK), CHANNEL_ID, expected), + ).rejects.toThrow(`channel ${CHANNEL_ID} has an invalid account discriminator`); expect(channelAccountMocks.fetchMaybeChannel).toHaveBeenCalledTimes(1); }); it("rejects a confirmed channel whose payee does not match the challenge", async () => { channelAccountMocks.fetchMaybeChannel.mockResolvedValue(existingAccount); await expect( - fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, { ...expected, payee: PAYER }), + fetchAndVerifyOpenChannel(accountFetchRpc(signer, NETWORK), CHANNEL_ID, { + ...expected, + payee: PAYER, + }), ).rejects.toThrow(/channel payee/); }); it("rejects a confirmed channel whose mint does not match the challenge", async () => { channelAccountMocks.fetchMaybeChannel.mockResolvedValue(existingAccount); await expect( - fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, { ...expected, mint: PAYEE }), + fetchAndVerifyOpenChannel(accountFetchRpc(signer, NETWORK), CHANNEL_ID, { + ...expected, + mint: PAYEE, + }), ).rejects.toThrow(/channel mint/); }); it("rejects a confirmed channel whose grace period does not match", async () => { channelAccountMocks.fetchMaybeChannel.mockResolvedValue(existingAccount); await expect( - fetchAndVerifyOpenChannel(signer, NETWORK, CHANNEL_ID, { ...expected, gracePeriod: 1 }), + fetchAndVerifyOpenChannel(accountFetchRpc(signer, NETWORK), CHANNEL_ID, { + ...expected, + gracePeriod: 1, + }), ).rejects.toThrow(/channel grace period/); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts index 7bbea1a3bf..2bcd4de132 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.facilitator.test.ts @@ -7,12 +7,12 @@ const channelMocks = vi.hoisted(() => ({ channelExists: vi.fn(), fetchAndVerifyOpenChannel: vi.fn(), simulateOpenSettleDistribute: vi.fn(), - submitSettle: vi.fn(), + submitChannelTransactionWithSigner: vi.fn(), })); -vi.mock("../../src/upto/facilitator/channel", async () => { - const actual = await vi.importActual( - "../../src/upto/facilitator/channel", +vi.mock("../../src/payment-channels/facilitator", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/facilitator", ); return { ...actual, @@ -20,7 +20,7 @@ vi.mock("../../src/upto/facilitator/channel", async () => { channelExists: channelMocks.channelExists, fetchAndVerifyOpenChannel: channelMocks.fetchAndVerifyOpenChannel, simulateOpenSettleDistribute: channelMocks.simulateOpenSettleDistribute, - submitSettle: channelMocks.submitSettle, + submitChannelTransactionWithSigner: channelMocks.submitChannelTransactionWithSigner, }; }); @@ -71,9 +71,9 @@ import { } from "../../src/upto/facilitator/delegatedAuthStore"; import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; import { + ChannelSimulationError as SettlementSimulationError, SettlementConfirmationTimeoutError, - SettlementSimulationError, -} from "../../src/upto/facilitator/channel"; +} from "../../src/payment-channels/facilitator"; import type { UptoChannelStorage } from "../../src/upto/facilitator/channelStorage"; import { UptoSvmRentCleanupManager } from "../../src/upto/facilitator/rentCleanupManager"; import type { UptoSvmPayloadV2 } from "../../src/types"; @@ -88,7 +88,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { channelMocks.channelExists.mockResolvedValue(false); channelMocks.simulateOpenSettleDistribute.mockResolvedValue(undefined); channelMocks.broadcastOpen.mockResolvedValue(USDC_MAINNET_ADDRESS); - channelMocks.submitSettle.mockResolvedValue(USDC_MAINNET_ADDRESS); + channelMocks.submitChannelTransactionWithSigner.mockResolvedValue(USDC_MAINNET_ADDRESS); }); async function buildFixture( @@ -179,7 +179,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(channelMocks.fetchAndVerifyOpenChannel).toHaveBeenCalledTimes(1); expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("passes the configured settle compute budget through to submitSettle", async () => { @@ -204,7 +204,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { ), ).resolves.toMatchObject({ success: true }); - expect(channelMocks.submitSettle).toHaveBeenCalledWith( + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledWith( expect.anything(), expect.anything(), SOLANA_DEVNET_CAIP2, @@ -242,7 +242,6 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect.anything(), expect.anything(), expect.anything(), - expect.anything(), { maxAttempts: 8, backoffStepMs: 50 }, ); }); @@ -257,7 +256,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { errorReason: "invalid_upto_svm_payload_settlement_exceeds_amount", }); expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); }); it("verify is read-only and never broadcasts", async () => { @@ -377,7 +376,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { { ...requirements, amount: "0" }, ), ).resolves.toMatchObject({ success: true, amount: "0" }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("indexes the channel on deposit settle and retains it after claim", async () => { @@ -540,7 +539,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { errorReason: "invalid_upto_svm_payload_missing_voucher", }); expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); }); it("rejects a forged voucher signature at claim settle", async () => { @@ -577,7 +576,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { ), ).resolves.toMatchObject({ success: true, amount: "0" }); // Zero-charge path: no voucher is submitted onchain (has_voucher = 0). - expect(channelMocks.submitSettle).toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalled(); }); describe("duplicate settlement cache", () => { @@ -613,7 +612,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: "duplicate_settlement", }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("rejects concurrent settles with different valid amounts after one claim", async () => { @@ -644,7 +643,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(successes).toHaveLength(1); expect(duplicates).toHaveLength(1); expect(["100", "200"]).toContain(successes[0]?.amount); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("does not claim the cache for an invalid voucher", async () => { @@ -665,12 +664,12 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: "invalid_upto_svm_payload_voucher_signature", }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); await expect( settleWithAmount(facilitator, payload, requirements, receiverAuthorizer, uptoPayload, "0"), ).resolves.toMatchObject({ success: true, amount: "0" }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("does not claim the cache when the channel is not open", async () => { @@ -686,12 +685,12 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: "invalid_upto_svm_channel_state", }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); await expect( settleWithAmount(facilitator, payload, requirements, receiverAuthorizer, uptoPayload, "0"), ).resolves.toMatchObject({ success: true, amount: "0" }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); }); @@ -718,7 +717,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(channelMocks.channelExists).not.toHaveBeenCalled(); expect(channelMocks.simulateOpenSettleDistribute).not.toHaveBeenCalled(); expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); }, ); @@ -745,7 +744,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { expect(channelMocks.channelExists).not.toHaveBeenCalled(); expect(channelMocks.simulateOpenSettleDistribute).not.toHaveBeenCalled(); expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); }); it("accepts verify payloads that omit voucherSignature", async () => { @@ -759,7 +758,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("returns success when channel storage upsert fails after confirmed settle", async () => { const settleSignature = "SettleSig1111111111111111111111111111111111"; const onStorageError = vi.fn(); - channelMocks.submitSettle.mockResolvedValue(settleSignature); + channelMocks.submitChannelTransactionWithSigner.mockResolvedValue(settleSignature); const failingStorage: UptoChannelStorage = { upsert: vi.fn().mockRejectedValue(new Error("storage unavailable")), get: vi.fn(), @@ -864,7 +863,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("warns when channel storage upsert fails and no onStorageError is configured", async () => { const settleSignature = "SettleSig2222222222222222222222222222222222"; - channelMocks.submitSettle.mockResolvedValue(settleSignature); + channelMocks.submitChannelTransactionWithSigner.mockResolvedValue(settleSignature); const failingStorage: UptoChannelStorage = { upsert: vi.fn().mockRejectedValue(new Error("storage unavailable")), get: vi.fn(), @@ -974,7 +973,9 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("returns transaction_failed when submitSettle fails before confirmation", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); - channelMocks.submitSettle.mockRejectedValue(new Error("rpc send failed")); + channelMocks.submitChannelTransactionWithSigner.mockRejectedValue( + new Error("rpc send failed"), + ); const voucherSignature = await signVoucher(receiverAuthorizer, { channelId: uptoPayload.channelId, cumulativeAmount: 0n, @@ -1002,7 +1003,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("returns settlement_pending with the broadcast signature when confirmation times out", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); - channelMocks.submitSettle.mockRejectedValue( + channelMocks.submitChannelTransactionWithSigner.mockRejectedValue( new SettlementConfirmationTimeoutError( "Sig11111111111111111111111111111111111111111" as never, ), @@ -1033,7 +1034,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("keeps the claim cache entry after a confirmation timeout, blocking a fresh re-submit", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); - channelMocks.submitSettle.mockRejectedValue( + channelMocks.submitChannelTransactionWithSigner.mockRejectedValue( new SettlementConfirmationTimeoutError( "Sig11111111111111111111111111111111111111111" as never, ), @@ -1050,13 +1051,13 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: ErrSettlementPending, }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); }); it("releases the claim cache when submitSettle fails so a retry can proceed", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); - channelMocks.submitSettle + channelMocks.submitChannelTransactionWithSigner .mockRejectedValueOnce(new Error("rpc send failed")) .mockResolvedValueOnce(USDC_MAINNET_ADDRESS as never); const voucherSignature = await signVoucher(receiverAuthorizer, { @@ -1075,7 +1076,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: true, amount: "0", }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(2); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(2); }); it("fails closed when channel storage upsert fails before deposit broadcast", async () => { @@ -1327,7 +1328,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { it("returns invalid_upto_svm_settlement_simulation when claim submitSettle simulation fails", async () => { const { facilitator, payload, requirements, receiverAuthorizer, uptoPayload } = await buildFixture(); - channelMocks.submitSettle.mockRejectedValue( + channelMocks.submitChannelTransactionWithSigner.mockRejectedValue( new SettlementSimulationError(new Error("sim failed")), ); const voucherSignature = await signVoucher(receiverAuthorizer, { @@ -1488,7 +1489,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { ), ).resolves.toMatchObject({ success: true, amount: "1000000" }); expect(channelMocks.broadcastOpen).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); expect(await store.get(uptoPayload.channelId, requirements.network)).toBeUndefined(); }); @@ -1547,7 +1548,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { { ...requirements, amount: "1858" }, ), ).resolves.toMatchObject({ success: true, amount: "1858" }); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); expect(resolveCallerIdentity).toHaveBeenCalledWith( expect.objectContaining({ step: "deposit", channelId: uptoPayload.channelId }), ); @@ -1572,7 +1573,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { ), ).resolves.toMatchObject({ success: true }); channelMocks.fetchAndVerifyOpenChannel.mockClear(); - channelMocks.submitSettle.mockClear(); + channelMocks.submitChannelTransactionWithSigner.mockClear(); await expect( facilitator.settle( @@ -1583,7 +1584,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: ERR_DELEGATED_SETTLE_UNAUTHENTICATED, }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); }); @@ -1612,7 +1613,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { errorReason: ERR_DELEGATED_AUTH_STORE, errorMessage: "failed to read delegated auth binding: store down", }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); }); @@ -1627,7 +1628,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: ERR_DELEGATED_SETTLE_UNAUTHENTICATED, }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); }); @@ -1666,7 +1667,7 @@ describe("UptoSvmScheme facilitator channel lifecycle", () => { success: false, errorReason: ERR_DELEGATED_SETTLE_UNAUTHENTICATED, }); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); }, ); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts index 724b3f08ed..5f83f46740 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.pendingSettlement.test.ts @@ -11,12 +11,12 @@ const channelMocks = vi.hoisted(() => ({ channelExists: vi.fn(), fetchAndVerifyOpenChannel: vi.fn(), simulateOpenSettleDistribute: vi.fn(), - submitSettle: vi.fn(), + submitChannelTransactionWithSigner: vi.fn(), })); -vi.mock("../../src/upto/facilitator/channel", async () => { - const actual = await vi.importActual( - "../../src/upto/facilitator/channel", +vi.mock("../../src/payment-channels/facilitator", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/facilitator", ); return { ...actual, @@ -24,7 +24,7 @@ vi.mock("../../src/upto/facilitator/channel", async () => { channelExists: channelMocks.channelExists, fetchAndVerifyOpenChannel: channelMocks.fetchAndVerifyOpenChannel, simulateOpenSettleDistribute: channelMocks.simulateOpenSettleDistribute, - submitSettle: channelMocks.submitSettle, + submitChannelTransactionWithSigner: channelMocks.submitChannelTransactionWithSigner, }; }); @@ -56,9 +56,9 @@ import { import { UptoSvmScheme } from "../../src/upto/facilitator/scheme"; import { ErrSettlementPending } from "../../src/exact/facilitator/errors"; import { - ChannelOpenConfirmationError, + ChannelBroadcastConfirmationError as ChannelOpenConfirmationError, SettlementConfirmationTimeoutError, -} from "../../src/upto/facilitator/channel"; +} from "../../src/payment-channels/facilitator"; import type { UptoSvmPayloadV2 } from "../../src/types"; import { challengeExpiresAt, MAX_TIMEOUT_SECONDS } from "./upto.testUtils"; @@ -163,7 +163,7 @@ describe("UptoSvmScheme deposit pending-settlement store integration", () => { channelMocks.channelExists.mockResolvedValue(false); channelMocks.simulateOpenSettleDistribute.mockResolvedValue(undefined); channelMocks.broadcastOpen.mockResolvedValue(USDC_MAINNET_ADDRESS); - channelMocks.submitSettle.mockResolvedValue(USDC_MAINNET_ADDRESS); + channelMocks.submitChannelTransactionWithSigner.mockResolvedValue(USDC_MAINNET_ADDRESS); store = new InMemoryPendingSettlementStore(); }); @@ -281,7 +281,7 @@ describe("UptoSvmScheme claim pending-settlement store integration", () => { channelMocks.channelExists.mockResolvedValue(false); channelMocks.simulateOpenSettleDistribute.mockResolvedValue(undefined); channelMocks.broadcastOpen.mockResolvedValue(USDC_MAINNET_ADDRESS); - channelMocks.submitSettle.mockResolvedValue(USDC_MAINNET_ADDRESS); + channelMocks.submitChannelTransactionWithSigner.mockResolvedValue(USDC_MAINNET_ADDRESS); store = new InMemoryPendingSettlementStore(); }); @@ -305,12 +305,12 @@ describe("UptoSvmScheme claim pending-settlement store integration", () => { const result = await fixture.facilitator.settle(payload, requirements); expect(result.success).toBe(true); - expect(channelMocks.submitSettle).toHaveBeenCalledTimes(1); + expect(channelMocks.submitChannelTransactionWithSigner).toHaveBeenCalledTimes(1); expect(await store.get(settlementKey)).toBeUndefined(); }); it("cache-miss + settle confirmation timeout: returns settlement_pending and populates the store keyed by the channel", async () => { - channelMocks.submitSettle.mockRejectedValue( + channelMocks.submitChannelTransactionWithSigner.mockRejectedValue( new SettlementConfirmationTimeoutError( "ClaimSig1111111111111111111111111111111111111" as never, ), @@ -341,7 +341,7 @@ describe("UptoSvmScheme claim pending-settlement store integration", () => { expect(result.success).toBe(true); expect(result.transaction).toBe("CachedClaimSig11111111111111111111111111111"); expect(channelMocks.fetchAndVerifyOpenChannel).not.toHaveBeenCalled(); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(confirmTransaction).toHaveBeenCalledWith( "CachedClaimSig11111111111111111111111111111", requirements.network, @@ -363,7 +363,7 @@ describe("UptoSvmScheme claim pending-settlement store integration", () => { expect(result.success).toBe(false); expect(result.errorReason).toBe(ErrSettlementPending); expect(result.transaction).toBe("CachedClaimSig22222222222222222222222222222"); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(await store.get(settlementKey)).toBe("CachedClaimSig22222222222222222222222222222"); }); @@ -379,7 +379,7 @@ describe("UptoSvmScheme claim pending-settlement store integration", () => { expect(result.success).toBe(false); expect(result.errorReason).toBe("invalid_upto_svm_payload_settlement_exceeds_amount"); - expect(channelMocks.submitSettle).not.toHaveBeenCalled(); + expect(channelMocks.submitChannelTransactionWithSigner).not.toHaveBeenCalled(); expect(await store.get(settlementKey)).toBeUndefined(); }); }); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts index cb5d068c4a..cb923577eb 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.rentCleanup.test.ts @@ -53,13 +53,13 @@ vi.mock("../../src/payment-channels/generated/accounts/channel", async () => { }; }); -vi.mock("../../src/upto/facilitator/channel", async () => { - const actual = await vi.importActual( - "../../src/upto/facilitator/channel", +vi.mock("../../src/payment-channels/facilitator", async () => { + const actual = await vi.importActual( + "../../src/payment-channels/facilitator", ); return { ...actual, - submitSettle: submitSettleMock, + submitChannelTransactionWithSigner: submitSettleMock, }; }); diff --git a/typescript/packages/mechanisms/svm/test/unit/upto.test.ts b/typescript/packages/mechanisms/svm/test/unit/upto.test.ts index 9859fbca71..edf7b86a68 100644 --- a/typescript/packages/mechanisms/svm/test/unit/upto.test.ts +++ b/typescript/packages/mechanisms/svm/test/unit/upto.test.ts @@ -50,26 +50,23 @@ import { parseU64, verifyOpenTransaction, } from "../../src/payment-channels/open"; +import { SLOT_COMMITMENT } from "../../src/payment-channels/commitments"; +import { parseTokenProgramHint, resolveUptoSvmMemo } from "../../src/payment-channels/requirements"; import { encodeVoucherMessageBytes, VOUCHER_MAGIC } from "../../src/payment-channels/voucher"; import { UptoSvmScheme as UptoClientScheme } from "../../src/upto/client/scheme"; -import { - parseTokenProgramHint, - resolveUptoSvmMemo, - resolveUptoSvmPaymentChannelConfig, - SLOT_COMMITMENT, -} from "../../src/upto/shared"; +import { resolveUptoSvmPaymentChannelConfig } from "../../src/upto/shared"; import { UptoSvmScheme as UptoServerScheme } from "../../src/upto/server/scheme"; import { DEFAULT_SETTLE_COMPUTE_UNIT_LIMIT, getChannelDistributionHash, reclaimComputeUnitLimit, broadcastOpen, - ChannelOpenConfirmationError, + ChannelBroadcastConfirmationError as ChannelOpenConfirmationError, simulateOpenSettleDistribute, SettlementConfirmationTimeoutError, - submitSettle, + submitChannelTransactionWithSigner as submitSettle, verifyOpenChannelAccount, -} from "../../src/upto/facilitator/channel"; +} from "../../src/payment-channels/facilitator"; import { ERR_SETTLEMENT_EXCEEDS_AMOUNT, UptoSvmScheme as UptoFacilitatorScheme, diff --git a/typescript/packages/mechanisms/svm/tsup.config.ts b/typescript/packages/mechanisms/svm/tsup.config.ts index db132c689a..63568ab29a 100644 --- a/typescript/packages/mechanisms/svm/tsup.config.ts +++ b/typescript/packages/mechanisms/svm/tsup.config.ts @@ -12,6 +12,9 @@ const baseConfig = { "upto/client/index": "src/upto/client/index.ts", "upto/server/index": "src/upto/server/index.ts", "upto/facilitator/index": "src/upto/facilitator/index.ts", + "batch-settlement/client/index": "src/batch-settlement/client/index.ts", + "batch-settlement/server/index": "src/batch-settlement/server/index.ts", + "batch-settlement/facilitator/index": "src/batch-settlement/facilitator/index.ts", }, dts: { resolve: true, @@ -32,5 +35,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/tvm/CHANGELOG.md b/typescript/packages/mechanisms/tvm/CHANGELOG.md index 82ac99b87d..8a3c1ad014 100644 --- a/typescript/packages/mechanisms/tvm/CHANGELOG.md +++ b/typescript/packages/mechanisms/tvm/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/tvm +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/tvm/package.json b/typescript/packages/mechanisms/tvm/package.json index 36590f1153..26e3414980 100644 --- a/typescript/packages/mechanisms/tvm/package.json +++ b/typescript/packages/mechanisms/tvm/package.json @@ -1,6 +1,6 @@ { "name": "@x402/tvm", - "version": "2.26.0", + "version": "2.27.0", "description": "TVM (TON) mechanism for x402 payment protocol", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", @@ -9,7 +9,7 @@ "author": "x402 Foundation", "repository": "https://github.com/x402-foundation/x402", "scripts": { - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:watch": "vitest", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", diff --git a/typescript/packages/mechanisms/tvm/tsup.config.ts b/typescript/packages/mechanisms/tvm/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/tvm/tsup.config.ts +++ b/typescript/packages/mechanisms/tvm/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/packages/mechanisms/xrpl/CHANGELOG.md b/typescript/packages/mechanisms/xrpl/CHANGELOG.md index 8bb51c3298..300879ab25 100644 --- a/typescript/packages/mechanisms/xrpl/CHANGELOG.md +++ b/typescript/packages/mechanisms/xrpl/CHANGELOG.md @@ -1,5 +1,12 @@ # @x402/xrpl +## 2.27.0 + +### Minor Changes + +- Updated dependencies [5d3a2b2](https://github.com/x402-foundation/x402/commit/5d3a2b2) + - @x402/core@2.27.0 + ## 2.26.0 ### Minor Changes diff --git a/typescript/packages/mechanisms/xrpl/package.json b/typescript/packages/mechanisms/xrpl/package.json index 60022c5602..5142176f4f 100644 --- a/typescript/packages/mechanisms/xrpl/package.json +++ b/typescript/packages/mechanisms/xrpl/package.json @@ -1,11 +1,11 @@ { "name": "@x402/xrpl", - "version": "2.26.0", + "version": "2.27.0", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js", "types": "./dist/cjs/index.d.ts", "scripts": { - "build": "tsup", + "build": "tsup && node ../../../scripts/mirror-cjs-dts.mjs", "test": "vitest run --coverage", "test:integration": "vitest run --config vitest.integration.config.ts", "test:watch": "vitest", diff --git a/typescript/packages/mechanisms/xrpl/tsup.config.ts b/typescript/packages/mechanisms/xrpl/tsup.config.ts index 1143bfeaf5..cc78a34a45 100644 --- a/typescript/packages/mechanisms/xrpl/tsup.config.ts +++ b/typescript/packages/mechanisms/xrpl/tsup.config.ts @@ -26,5 +26,6 @@ export default defineConfig([ format: "cjs", outDir: "dist/cjs", clean: false, + dts: false, }, ]); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index b5b805eab9..5167d4ecc3 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -1890,6 +1890,9 @@ importers: '@solana/kit': specifier: '>=5.1.0' version: 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) + '@solana/program-client-core': + specifier: ^6.1.0 + version: 6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@x402/core': specifier: workspace:~ version: link:../../core @@ -2155,6 +2158,9 @@ importers: '@typescript-eslint/parser': specifier: ^8.29.1 version: 8.49.0(eslint@9.34.0)(typescript@5.9.2) + '@x402/fetch': + specifier: workspace:* + version: link:../packages/http/fetch eslint: specifier: ^9.24.0 version: 9.34.0 @@ -2173,6 +2179,12 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.2 + vite: + specifier: ^6.2.6 + version: 6.3.5(@types/node@22.19.17)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) + vitest: + specifier: ^3.2.7 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.19.17)(jsdom@26.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) packages: diff --git a/typescript/scripts/mirror-cjs-dts.mjs b/typescript/scripts/mirror-cjs-dts.mjs new file mode 100644 index 0000000000..7695a6ce77 --- /dev/null +++ b/typescript/scripts/mirror-cjs-dts.mjs @@ -0,0 +1,223 @@ +#!/usr/bin/env node +/** + * Mirrors the ESM declaration files tsup already generated (dist/esm) into dist/cjs, + * instead of running tsup's (expensive) DTS rollup a second time for the CJS build. + * Package type content does not differ between module formats — only the runtime + * import/export syntax does — so generating it twice is pure duplicate work that + * roughly doubles peak memory during `tsup` (measured: @x402/evm 5.4GB -> 2.7GB, + * @x402/svm 3.3GB -> 1.7GB, @x402/core 2.0GB -> 1.0GB, @x402/extensions 1.6GB -> 0.8GB + * when generated once instead of twice). + * + * Applied to every tsup-based package in this workspace except `typescript/packages/legacy/*`, + * which is intentionally excluded: it is frozen (security patches only) per + * `.agents/skills/contributing/SKILL.md`, so its build config is left untouched. + * + * Handles both declaration-extension conventions used across this workspace's packages, + * determined from each package's own `package.json` `"type"` field (authoritative — + * this is what actually determines the convention tsup uses, so it is read directly + * rather than inferred from build output, which can be empty, stale, or mixed): + * - No `"type": "module"`: ESM declarations are `.d.mts` (the explicit extension + * disambiguates them from the CJS build's plain `.d.ts`). Mirrored to `.d.ts`, + * rewriting internal `.mjs` specifiers to `.js`. + * - `"type": "module"`: ESM declarations are plain `.d.ts` (already unambiguous, + * since ESM is the default). Mirrored to `.d.cts`, rewriting internal `.js` + * specifiers to `.cjs` (matching how tsup names *this* convention's actual CJS JS + * output). + * The expected source files are also scanned as a consistency check: if none are + * found where the declared `"type"` says they should be, or if we find the *other* + * convention's files instead, that indicates a stale or failed ESM build, and this + * script exits 1 rather than silently mirroring zero (or wrong) files. + * + * Before writing, every existing `dist/cjs` file matching the target suffix (and + * companion `*.d.*.map` declaration sourcemaps when present) is deleted, since tsup's + * CJS config uses `clean: false` (ESM's `clean: true` already removes stale dist/esm + * output). Without this, a renamed entry or a chunk whose content-hash changed would + * leave its old declaration file behind, silently masking a type that no longer has + * a live source. + * + * Mirrored files' internal relative import/export specifiers are rewritten only to the + * extension matching the target format; the referenced chunk hash names themselves are + * left as-is (identical to the ESM build's), which will not literally match the CJS + * build's own (independently, differently-hashed) JS chunk files. That's fine: `.d.ts` + * files are compile-time-only and never `require()`/`import()`-ed at runtime; TypeScript + * only needs the referenced declaration file to exist, which this script guarantees by + * mirroring every chunk, not just each entry point's own file. + * + * Run from a package's root (i.e. as a step in that package's own `build` script, after + * `tsup`), operating on `dist/esm` and `dist/cjs` relative to the current working + * directory. + */ + +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs' +import { dirname, join } from 'node:path' + +const ESM_DIR = 'dist/esm' +const CJS_DIR = 'dist/cjs' + +/** + * @param message - Error detail + * @returns Nothing; prints and exits 1 + */ +function fail(message) { + console.error(`[mirror-cjs-dts] ${message}`) + process.exit(1) +} + +/** + * Recursively collects every file under a directory whose name ends with `suffix`. + * Returns an empty array (rather than throwing) when `dir` does not exist, so callers + * can use it for the CJS side before any CJS output has ever been written. + * + * @param dir - Directory to search + * @param suffix - File name suffix to match (e.g. `.d.mts`) + * @param files - Accumulator (used for the recursive call; omit at the top level) + * @returns Paths (relative to `process.cwd()`) of every matching file found + */ +function findFiles(dir, suffix, files = []) { + if (!existsSync(dir)) return files + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + let isDirectory + try { + isDirectory = statSync(full).isDirectory() + } catch (err) { + fail(`Cannot stat "${full}": ${err.message}`) + } + if (isDirectory) { + findFiles(full, suffix, files) + } else if (entry.endsWith(suffix)) { + files.push(full) + } + } + return files +} + +/** + * Reads whether this package is ESM-by-default, from its own `package.json` — the + * authoritative source for which declaration-extension convention tsup used, since + * build output can be empty, stale, or (after a manual partial rebuild) mixed. + * + * @returns True when `package.json` declares `"type": "module"` + */ +function isTypeModulePackage() { + let raw + try { + raw = readFileSync('package.json', 'utf8') + } catch (err) { + fail(`Cannot read package.json: ${err.message}`) + } + let pkg + try { + pkg = JSON.parse(raw) + } catch (err) { + fail(`Invalid JSON in package.json: ${err.message}`) + } + return pkg.type === 'module' +} + +/** + * Rewrites ESM declaration text for the mirrored CJS declaration file. + * + * @param content - ESM declaration file contents + * @param options - Suffix and runtime extension mapping for this package convention + * @returns Content suitable for the CJS-side declaration file + */ +function rewriteDeclarationContent(content, { sourceSuffix, targetSuffix, sourceJsExt, targetJsExt }) { + // Assumes tsup DTS output only uses quoted relative import/export specifiers whose + // paths end in `.mjs` (non-"type":"module" packages) or `.js` ("type":"module"). + // Does not rewrite `/// `, query/hash imports, or specifiers + // inside comments — extend this if a tsup upgrade changes declaration shape. + const specifierPattern = new RegExp(`(['"]\\.[^'"]*?)\\${sourceJsExt}(['"])`, 'g') + const sourceMapSuffix = `${sourceSuffix}.map` + const targetMapSuffix = `${targetSuffix}.map` + return content + .replace(specifierPattern, `$1${targetJsExt}$2`) + .replaceAll(sourceMapSuffix, targetMapSuffix) +} + +/** + * Mirrors every ESM declaration file into the CJS output directory. + * + * @returns Nothing; writes files and logs a summary to stdout, or exits 1 on failure + */ +function main() { + if (!existsSync(ESM_DIR)) { + fail( + `${ESM_DIR} does not exist. Run this script from a package root, ` + + 'as a build step after tsup (e.g. `tsup && node .../mirror-cjs-dts.mjs`).', + ) + } + + const typeModule = isTypeModulePackage() + const sourceSuffix = typeModule ? '.d.ts' : '.d.mts' + const otherSuffix = typeModule ? '.d.mts' : '.d.ts' + const targetSuffix = typeModule ? '.d.cts' : '.d.ts' + const sourceJsExt = typeModule ? '.js' : '.mjs' + const targetJsExt = typeModule ? '.cjs' : '.js' + const sourceMapSuffix = `${sourceSuffix}.map` + const targetMapSuffix = `${targetSuffix}.map` + + const rewriteOptions = { sourceSuffix, targetSuffix, sourceJsExt, targetJsExt } + + const sourceFiles = findFiles(ESM_DIR, sourceSuffix) + + // Consistency check: package.json's "type" field determines the convention tsup + // actually used, but a stale ESM build (e.g. left over from before "type" changed, + // or from a failed partial rebuild) could disagree with it. Finding files in the + // *other* convention's shape, or none in the expected one, means the ESM build is + // not in the state this script assumes — fail loudly rather than silently mirroring + // nothing (or mirroring stale, wrongly-shaped files). + if (sourceFiles.length === 0) { + const foundOther = findFiles(ESM_DIR, otherSuffix).length + const hint = foundOther + ? ` Found ${foundOther} "${otherSuffix}" file(s) instead — package.json's "type" ` + + `field may not match what the ESM build actually produced; rebuild with \`tsup\` first.` + : '' + fail(`No "${sourceSuffix}" declaration files found under ${ESM_DIR}.${hint}`) + } + + // Prune stale mirrors before writing fresh ones. tsup's CJS config uses + // `clean: false`, so a renamed entry or a chunk whose content-hash changed since + // the last build would otherwise leave its old declaration file behind, masking a + // type that no longer has a live ESM source. + for (const staleFile of findFiles(CJS_DIR, targetSuffix)) { + unlinkSync(staleFile) + } + for (const staleMap of findFiles(CJS_DIR, targetMapSuffix)) { + unlinkSync(staleMap) + } + + let mirroredMaps = 0 + + for (const esmFile of sourceFiles) { + const relative = esmFile.slice(ESM_DIR.length + 1) + const cjsFile = join(CJS_DIR, relative.slice(0, -sourceSuffix.length) + targetSuffix) + mkdirSync(dirname(cjsFile), { recursive: true }) + const content = rewriteDeclarationContent(readFileSync(esmFile, 'utf8'), rewriteOptions) + writeFileSync(cjsFile, content) + + const esmMapFile = esmFile.slice(0, -sourceSuffix.length) + sourceMapSuffix + if (existsSync(esmMapFile)) { + const cjsMapFile = join(CJS_DIR, relative.slice(0, -sourceSuffix.length) + targetMapSuffix) + mkdirSync(dirname(cjsMapFile), { recursive: true }) + writeFileSync(cjsMapFile, readFileSync(esmMapFile)) + mirroredMaps += 1 + } + } + + const mapSummary = + mirroredMaps > 0 ? ` and ${mirroredMaps} declaration sourcemap(s)` : '' + console.log( + `[mirror-cjs-dts] Mirrored ${sourceFiles.length} declaration file(s)${mapSummary} into ${CJS_DIR}`, + ) +} + +main() diff --git a/typescript/scripts/verify-package-exports.mjs b/typescript/scripts/verify-package-exports.mjs new file mode 100644 index 0000000000..e1ebea8c53 --- /dev/null +++ b/typescript/scripts/verify-package-exports.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * Verifies that every `"types"` condition target in every workspace package's + * `package.json` `"exports"` map actually exists on disk, post-build. + * + * This is a safety net for build-config changes like `mirror-cjs-dts.mjs` + * (typescript/scripts/mirror-cjs-dts.mjs): a script or tsup config mistake there could + * leave a declared `.d.ts`/`.d.cts` target missing while the build itself still exits 0, + * silently shipping a package that resolves at runtime but has no types for some + * subpath. Run this after building the workspace (e.g. `pnpm build && pnpm verify:exports` + * from `typescript/`, or rely on CI/publish workflows that run `pnpm verify:exports`). + * + * Scans every package under the directories in {@link PACKAGE_GLOBS}, including + * `packages/legacy/*` (this only reads already-published-shape config; it does not + * modify anything, so it's safe to run there too). + * + * Packages with no `dist/` directory are skipped (expected for scoped builds). Exits + * non-zero when no package was checked unless `VERIFY_EXPORTS_ALLOW_EMPTY=1`. + */ + +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const TYPESCRIPT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** Directories (relative to the `typescript/` root) whose immediate subdirectories are packages. */ +const PACKAGE_GLOBS = [ + 'packages', // core, extensions, mcp themselves live directly here + 'packages/http', + 'packages/mechanisms', + 'packages/legacy', +] + +/** + * @param message - Error detail + * @returns Nothing; prints and exits 1 + */ +function fail(message) { + console.error(`[verify-package-exports] ${message}`) + process.exit(1) +} + +/** + * Lists directories directly under `dir` that contain a `package.json`. + * + * @param dir - Absolute directory to scan + * @returns Absolute paths of immediate child directories containing a `package.json` + */ +function listPackageDirs(dir) { + if (!existsSync(dir)) return [] + return readdirSync(dir) + .map((entry) => join(dir, entry)) + .filter((full) => { + let isDirectory + try { + isDirectory = statSync(full).isDirectory() + } catch (err) { + fail(`Cannot stat "${full}": ${err.message}`) + } + return isDirectory && existsSync(join(full, 'package.json')) + }) +} + +/** + * @param packageDir - Absolute path to the package directory + * @returns Parsed package.json object + */ +function readPackageJson(packageDir) { + const manifestPath = join(packageDir, 'package.json') + let raw + try { + raw = readFileSync(manifestPath, 'utf8') + } catch (err) { + fail(`Cannot read "${manifestPath}": ${err.message}`) + } + try { + return JSON.parse(raw) + } catch (err) { + fail(`Invalid JSON in "${manifestPath}": ${err.message}`) + } +} + +/** + * Recursively collects every string value found under an `"exports"` map's `"types"` + * keys, at any nesting depth (subpaths, conditions, arrays of fallback conditions). + * + * @param node - Current `exports` subtree (object, array, or string) + * @param inTypesKey - Whether `node` is itself the value of a `"types"` key + * @param found - Accumulator (used for the recursive call; omit at the top level) + * @returns Every `"types"` target string found in this subtree + */ +function collectTypesTargets(node, inTypesKey = false, found = []) { + if (typeof node === 'string') { + if (inTypesKey) found.push(node) + return found + } + if (Array.isArray(node)) { + for (const entry of node) collectTypesTargets(entry, inTypesKey, found) + return found + } + if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + collectTypesTargets(value, key === 'types', found) + } + } + return found +} + +/** + * Verifies one package's `exports.*.types` targets exist on disk. Returns `null` + * (skip) for a package with no `dist` directory at all: a scoped build (e.g. `turbo + * run build --filter=site...`) only builds a subset of the workspace, so an unbuilt + * sibling package here is expected, not a missing-types regression. A package that + * turbo actually ran and that legitimately failed to produce output would already + * have failed that build step with a non-zero exit before this script ever runs. + * + * @param packageDir - Absolute path to the package directory + * @returns Missing target paths (relative to the package directory), or `null` if + * this package was skipped (not built in this run) + */ +function verifyPackage(packageDir) { + const pkg = readPackageJson(packageDir) + if (!pkg.exports) return [] + if (!existsSync(join(packageDir, 'dist'))) return null + + const targets = collectTypesTargets(pkg.exports) + return targets.filter((target) => !existsSync(join(packageDir, target))) +} + +/** + * Verifies every workspace package's `exports.*.types` targets. + * + * @returns Nothing; prints a report and exits 1 if any target is missing + */ +function main() { + const packageDirs = PACKAGE_GLOBS.flatMap((glob) => listPackageDirs(join(TYPESCRIPT_ROOT, glob))) + + if (packageDirs.length === 0) { + fail('No workspace packages found (check PACKAGE_GLOBS).') + } + + let checkedCount = 0 + let skippedCount = 0 + let missingCount = 0 + + for (const packageDir of packageDirs) { + const missing = verifyPackage(packageDir) + if (missing === null) { + skippedCount += 1 + continue + } + checkedCount += 1 + if (missing.length > 0) { + missingCount += missing.length + const name = readPackageJson(packageDir).name + for (const target of missing) { + console.error(`[verify-package-exports] ${name}: missing "${target}" (declared in exports)`) + } + } + } + + if (missingCount > 0) { + console.error( + `[verify-package-exports] ${missingCount} missing types target(s) across ${checkedCount} built package(s) (${skippedCount} unbuilt package(s) skipped).`, + ) + process.exit(1) + } + + const allowEmpty = process.env.VERIFY_EXPORTS_ALLOW_EMPTY === '1' + if (checkedCount === 0 && !allowEmpty) { + fail( + `No built packages to check (${skippedCount} package(s) have no dist/). ` + + 'Run a workspace build first (e.g. `pnpm build` from typescript/). ' + + 'Set VERIFY_EXPORTS_ALLOW_EMPTY=1 to allow exit 0 with zero checks.', + ) + } + + console.log( + `[verify-package-exports] All exports.*.types targets exist (${checkedCount} built package(s) checked, ${skippedCount} unbuilt package(s) skipped).`, + ) +} + +main() diff --git a/typescript/site/app/facilitator/index.ts b/typescript/site/app/facilitator/index.ts index be034982d6..e04ce8d843 100644 --- a/typescript/site/app/facilitator/index.ts +++ b/typescript/site/app/facilitator/index.ts @@ -120,11 +120,54 @@ async function createFacilitator(): Promise { const svmSigner = toFacilitatorSvmSigner(svmAccount); // Create and configure the facilitator with all networks - // EIP6492 allowed factory addresses for x402.org testnet facilitator + // EIP6492 allowed factory addresses for x402.org testnet facilitator. + // Ported from the CDP Facilitator's allowlist to keep trusted factories in parity. // To extend support for new factories, add more factory addresses to the array. const eip6492AllowedFactories = [ - "0x0BA5ED0c6AA8c49038F819E587E2633c4A9F428a", // CoinbaseSmartWalletFactory v1 - "0xBA5ED110eFDBa3D005bfC882d75358ACBbB85842", // CoinbaseSmartWalletFactory v1.1 + // Coinbase Smart Wallet (CoinbaseSmartWalletFactory) + "0x0BA5ED0c6AA8c49038F819E587E2633c4A9F428a", // v1 + "0xBA5ED110eFDBa3D005bfC882d75358ACBbB85842", // v1.1 + // Safe / Gnosis Safe (SafeProxyFactory) + "0x76E2cFc1F5Fa8F6a5b3fC4c8F4788F0116861F9B", // v1.1.1 + "0x6851D6fDFAfD08c0295C392436245E5bc78B0185", // v1.2.0 + "0xa6B71E26C5e0845f74c812102Ca7114b6a896AB2", // v1.3.0 (canonical) + "0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC", // v1.3.0 (singleton) + "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67", // v1.4.0/v1.4.1 (also used by Candide SafeAccountV2) + "0xc329D02fd8CB2fc13aa919005aF46320794a8629", // v1.4.1 zkSync + "0x14F2982D601c9458F93bd70B218933A6f8165e7b", // v1.5.0 + // ZeroDev Kernel (KernelFactory) — also used by Crossmint + "0xd703aaE79538628d27099B8c4f621bE4CCd142d5", // Kernel MetaFactory (v3.x shared, all v3 deployments) + "0x4E4946298614FC299B50c947289F4aD0572CB9ce", // v1.0 (EP v0.6) + "0x12358cA00141D09cB90253F05a1DD16bE93A8EE6", // v2.0 (EP v0.6) + "0x5de4839a76cf55d0c90e2061ef4386d962E15ae3", // v2.1-v2.4 (EP v0.6) + "0x6723b44Abeec4E71eBE3232BD5B455805baDD22f", // v3.0 (EP v0.7) + "0xaac5D4240AF87249B3f71BC8E4A2cae074A3E419", // v3.1 (EP v0.7) + "0x7a1dBAB750f12a90EB1B60D2Ae3aD17D4D81EfFe", // v3.2 (EP v0.7) + "0x2577507b78c2008Ff367261CB6285d44ba5eF2E9", // v3.3 (EP v0.7) + // Biconomy (NexusAccountFactory / BiconomySmartAccountFactory) + "0x000000F9eE1842Bb72F6BBDD75E6D3d4e3e9594C", // Smart Account V1 (EP v0.6) + "0x000000a56Aaca3e9a4C479ea6b6CD0DbcB6634F5", // Smart Account V2 (EP v0.6) + "0x0000002D6DB27c52E3C11c1Cf24072004AC0562a", // Nexus v1.0.0 + "0x0000000C8B6b3329cEa5d15C9d8C15F1f254ec3C", // Nexus v1.0.2 + "0x000000001D1D5004a02bAfAb9de2D6CE5b7B13de", // Nexus v1.2.0 (chain-specific) + "0x0000006648ED9B2B842552BE63Af870bC74af837", // Nexus v1.2.0 (MEE v2.0.0) + "0x000000002c9A405a196f2dc766F2476B731693c3", // Nexus v1.3.0 (MEE v2.1.0) + "0x5836Bdb35913c7CBA6ef40675354445121449917", // Nexus v1.3.1 (MEE v2.1.0 latest) + // Alchemy Light Account (LightAccountFactory) + "0x000000893A26168158fbeaDD9335Be5bC96592E2", // v1.0.1 + "0x00000055C0b4fA41dde26A74435ff03692292FBD", // v1.0.2 + "0x00004EC70002a32400f8ae005A26081065620D20", // v1.1.0 + "0x0000000000400CdFef5E2714E63d8040b700BC24", // v2.0.0 + "0x000000000019d2Ee9F2729A65AfE20bb0020AefC", // MultiOwner v2.0.0 + // Alchemy Modular Account + "0x000000e92D78D90000007F0082006FDA09BD5f11", // V1 MultiOwnerModularAccountFactory (EP v0.6) + "0x00000000000017c61b5bEe81050EC8eFc9c6fecd", // V2 AccountFactory (EP v0.7) + // eth-infinitism SimpleAccount + "0x9406Cc6185a346906296840746125a0E44976454", // SimpleAccountFactory (EP v0.6) + "0x91E60e0613810449d098b0b5Ec8b51A0FE8c8985", // SimpleAccountFactory (EP v0.7) + // Thirdweb (AccountFactory) + "0x85e23b94e7F5E9cC1fF78BCe78cfb15B81f0DF00", // EP v0.6 + "0x4bE0ddfebcA9A5A4a617dee4DeCe99E7c862dceb", // EP v0.7 ]; const facilitator = new x402Facilitator() diff --git a/typescript/site/app/protected/evm/exact/erc3009/route.ts b/typescript/site/app/protected/evm/exact/erc3009/route.ts new file mode 100644 index 0000000000..0e9bbd0924 --- /dev/null +++ b/typescript/site/app/protected/evm/exact/erc3009/route.ts @@ -0,0 +1,43 @@ +import { withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildExactErc3009Accept, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `exact` / erc3009 endpoint. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment + */ +const handler = async (_: NextRequest): Promise => { + return NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "erc3009", + }); +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/exact/erc3009": { + accepts: buildExactErc3009Accept(), + description: "EVM exact testnet endpoint (erc3009 only)", + mimeType: "application/json", + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and use a client that " + + "supports EIP-3009 (transferWithAuthorization), such as the canonical x402 SDKs " + + "(@x402/evm), which do by default.", +); diff --git a/typescript/site/app/protected/evm/exact/permit2/eip2612/route.ts b/typescript/site/app/protected/evm/exact/permit2/eip2612/route.ts new file mode 100644 index 0000000000..f7b4916b28 --- /dev/null +++ b/typescript/site/app/protected/evm/exact/permit2/eip2612/route.ts @@ -0,0 +1,48 @@ +import { withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildExactPermit2Accept, + EIP2612_EXTENSION, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `exact` / permit2 / eip2612 endpoint. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment + */ +const handler = async (_: NextRequest): Promise => { + return NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "permit2", + extension: "eip2612GasSponsoring", + }); +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/exact/permit2/eip2612": { + accepts: buildExactPermit2Accept(), + description: "EVM exact testnet endpoint (permit2 with gasless EIP-2612 approval)", + mimeType: "application/json", + extensions: { + ...EIP2612_EXTENSION, + }, + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and use a client that " + + "supports Permit2 with EIP-2612 gasless approval, such as the canonical x402 " + + "SDKs (@x402/evm), which do by default.", +); diff --git a/typescript/site/app/protected/evm/exact/permit2/route.ts b/typescript/site/app/protected/evm/exact/permit2/route.ts new file mode 100644 index 0000000000..2884c3cd53 --- /dev/null +++ b/typescript/site/app/protected/evm/exact/permit2/route.ts @@ -0,0 +1,44 @@ +import { withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildExactPermit2Accept, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `exact` / permit2 (no extension) endpoint. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment + */ +const handler = async (_: NextRequest): Promise => { + return NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "permit2", + }); +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/exact/permit2": { + accepts: buildExactPermit2Accept(), + description: "EVM exact testnet endpoint (permit2, no gas-sponsoring extension)", + mimeType: "application/json", + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and use a client that " + + "supports Permit2, such as the canonical x402 SDKs (@x402/evm). This route does " + + "not offer gasless approval, so the paying account must already have approved " + + "the canonical Permit2 contract to spend the token.", +); diff --git a/typescript/site/app/protected/evm/exact/route.ts b/typescript/site/app/protected/evm/exact/route.ts new file mode 100644 index 0000000000..3f467760f2 --- /dev/null +++ b/typescript/site/app/protected/evm/exact/route.ts @@ -0,0 +1,47 @@ +import { withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildExactErc3009Accept, + buildExactPermit2Accept, + EIP2612_EXTENSION, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `exact` default (multi-accept) endpoint. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment + */ +const handler = async (_: NextRequest): Promise => { + return NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "exact", + }); +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/exact": { + accepts: [buildExactErc3009Accept(), buildExactPermit2Accept()], + description: "EVM exact testnet endpoint (erc3009 or permit2)", + mimeType: "application/json", + extensions: { + ...EIP2612_EXTENSION, + }, + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and use a client that " + + "supports EIP-3009 or Permit2 (optionally with EIP-2612), such as the canonical " + + "x402 SDKs (@x402/evm), which support both by default.", +); diff --git a/typescript/site/app/protected/evm/upto/permit2/eip2612/route.ts b/typescript/site/app/protected/evm/upto/permit2/eip2612/route.ts new file mode 100644 index 0000000000..7279715135 --- /dev/null +++ b/typescript/site/app/protected/evm/upto/permit2/eip2612/route.ts @@ -0,0 +1,54 @@ +import { setSettlementOverrides, withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildUptoAccept, + EIP2612_EXTENSION, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + UPTO_SETTLEMENT_OVERRIDE, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `upto` / permit2 / eip2612 endpoint. Authorizes 2x + * the exact price but settles only {@link UPTO_SETTLEMENT_OVERRIDE} of it. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment, with the settlement override applied + */ +const handler = async (_: NextRequest): Promise => { + const response = NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "upto", + assetTransferMethod: "permit2", + extension: "eip2612GasSponsoring", + settledPercentOfAuthorized: UPTO_SETTLEMENT_OVERRIDE, + }); + setSettlementOverrides(response, { amount: UPTO_SETTLEMENT_OVERRIDE }); + return response; +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/upto/permit2/eip2612": { + accepts: buildUptoAccept(), + description: "EVM upto testnet endpoint (permit2 with gasless EIP-2612 approval)", + mimeType: "application/json", + extensions: { + ...EIP2612_EXTENSION, + }, + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and the upto scheme, and " + + "use a client that supports Permit2 with EIP-2612 gasless approval and partial " + + "settlement, such as the canonical x402 SDKs (@x402/evm), which do by default. " + + "This route authorizes 2x the advertised price but settles only 50% of it.", +); diff --git a/typescript/site/app/protected/evm/upto/permit2/route.ts b/typescript/site/app/protected/evm/upto/permit2/route.ts new file mode 100644 index 0000000000..8438468bca --- /dev/null +++ b/typescript/site/app/protected/evm/upto/permit2/route.ts @@ -0,0 +1,50 @@ +import { setSettlementOverrides, withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildUptoAccept, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + UPTO_SETTLEMENT_OVERRIDE, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `upto` / permit2 (no extension) endpoint. Authorizes + * 2x the exact price but settles only {@link UPTO_SETTLEMENT_OVERRIDE} of it. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment, with the settlement override applied + */ +const handler = async (_: NextRequest): Promise => { + const response = NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "upto", + assetTransferMethod: "permit2", + settledPercentOfAuthorized: UPTO_SETTLEMENT_OVERRIDE, + }); + setSettlementOverrides(response, { amount: UPTO_SETTLEMENT_OVERRIDE }); + return response; +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/upto/permit2": { + accepts: buildUptoAccept(), + description: "EVM upto testnet endpoint (permit2, no gas-sponsoring extension)", + mimeType: "application/json", + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and the upto scheme, and " + + "use a client that supports Permit2 and partial settlement, such as the " + + "canonical x402 SDKs (@x402/evm). This route does not offer gasless approval, so " + + "the paying account must already have approved the canonical Permit2 contract. " + + "This route authorizes 2x the advertised price but settles only 50% of it.", +); diff --git a/typescript/site/app/protected/evm/upto/route.ts b/typescript/site/app/protected/evm/upto/route.ts new file mode 100644 index 0000000000..42b4d95312 --- /dev/null +++ b/typescript/site/app/protected/evm/upto/route.ts @@ -0,0 +1,52 @@ +import { setSettlementOverrides, withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildUptoAccept, + EIP2612_EXTENSION, + evmResourceServer, + SYNC_FACILITATOR_ON_START, + UPTO_SETTLEMENT_OVERRIDE, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the EVM `upto` default endpoint. Authorizes 2x the exact + * price but settles only {@link UPTO_SETTLEMENT_OVERRIDE} of it. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment, with the settlement override applied + */ +const handler = async (_: NextRequest): Promise => { + const response = NextResponse.json({ + ok: true, + caip2Family: "evm", + scheme: "upto", + settledPercentOfAuthorized: UPTO_SETTLEMENT_OVERRIDE, + }); + setSettlementOverrides(response, { amount: UPTO_SETTLEMENT_OVERRIDE }); + return response; +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/evm/upto": { + accepts: buildUptoAccept(), + description: "EVM upto testnet endpoint (permit2 with gasless EIP-2612 approval)", + mimeType: "application/json", + extensions: { + ...EIP2612_EXTENSION, + }, + }, + }, + evmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the EVM mechanism and the upto scheme, and " + + "use a client that supports Permit2 with EIP-2612 gasless approval and partial " + + "settlement, such as the canonical x402 SDKs (@x402/evm), which do by default. " + + "This route authorizes 2x the advertised price but settles only 50% of it.", +); diff --git a/typescript/site/app/protected/svm/exact/route.ts b/typescript/site/app/protected/svm/exact/route.ts new file mode 100644 index 0000000000..629d6396fa --- /dev/null +++ b/typescript/site/app/protected/svm/exact/route.ts @@ -0,0 +1,41 @@ +import { withX402 } from "@x402/next"; +import { NextRequest, NextResponse } from "next/server"; +import { + buildSvmExactAccept, + svmResourceServer, + SYNC_FACILITATOR_ON_START, + withJsonPaymentRequired, +} from "@/lib/testnetProtectedResources"; + +/** + * Testnet demo handler for the SVM `exact` endpoint. + * + * @param _ - Incoming Next.js request (unused; content is static) + * @returns JSON confirmation of successful payment + */ +const handler = async (_: NextRequest): Promise => { + return NextResponse.json({ + ok: true, + caip2Family: "svm", + scheme: "exact", + }); +}; + +export const GET = withJsonPaymentRequired( + withX402( + handler, + { + "GET /protected/svm/exact": { + accepts: buildSvmExactAccept(), + description: "SVM exact testnet endpoint", + mimeType: "application/json", + }, + }, + svmResourceServer, + undefined, + undefined, + SYNC_FACILITATOR_ON_START, + ), + "To access this, register support for the SVM mechanism, such as the canonical " + + "x402 SDKs (@x402/svm), which do by default.", +); diff --git a/typescript/site/lib/testnetProtectedResources.ts b/typescript/site/lib/testnetProtectedResources.ts new file mode 100644 index 0000000000..7931d281be --- /dev/null +++ b/typescript/site/lib/testnetProtectedResources.ts @@ -0,0 +1,211 @@ +import { HTTPFacilitatorClient, x402ResourceServer } from "@x402/core/server"; +import { + decodePaymentRequiredHeader, + decodePaymentResponseHeader, + PaymentOption, +} from "@x402/core/http"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { UptoEvmScheme } from "@x402/evm/upto/server"; +import { declareEip2612GasSponsoringExtension } from "@x402/extensions"; +import { ExactSvmScheme } from "@x402/svm/exact/server"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Shared configuration and resource servers for the `/protected/{evm,svm}/{exact,upto}...` + * testnet demo endpoints. Deliberately separate from proxy.ts's `/protected` config so + * that route stays functionally unchanged. + */ + +export const EVM_NETWORK = "eip155:84532" as const; // Base Sepolia +export const SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" as const; // Solana Devnet + +export const evmPayeeAddress = process.env.RESOURCE_EVM_ADDRESS as `0x${string}`; +export const svmPayeeAddress = process.env.RESOURCE_SVM_ADDRESS as string; + +const facilitatorUrl = process.env.FACILITATOR_URL as string; +if (!facilitatorUrl) { + console.error("❌ FACILITATOR_URL environment variable is required"); +} + +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +/** Base price for the `exact` testnet endpoints. */ +export const EXACT_PRICE = "$0.001"; +/** `upto` testnet endpoints authorize 2x the exact price. */ +export const UPTO_PRICE = "$0.002"; +/** `upto` testnet endpoints settle 50% of the authorized amount (i.e. the exact price). */ +export const UPTO_SETTLEMENT_OVERRIDE = "50%"; + +/** Route-level extension declaration enabling gasless EIP-2612 Permit2 approval. */ +export const EIP2612_EXTENSION = declareEip2612GasSponsoringExtension(); + +/** + * `withX402`/`paymentProxyFromConfig` default to eagerly (backgrounded, + * fire-and-forget) calling the facilitator's `/supported` on construction, so a real + * first request doesn't pay that latency. That construction — and thus the eager call + * — also happens when Next.js evaluates each route module during `next build` to + * bundle/trace it, not just at real runtime. Locally that's harmless (an unreachable + * dev facilitator URL fails instantly), but against a deploy target where + * `FACILITATOR_URL` is slow or doesn't fail fast, every resource-server-bearing route + * module independently starting this call at build time is a plausible source of a + * hung/timed-out build with no code change involved. `NEXT_PHASE` is set to + * `"phase-production-build"` by Next.js only during that build step, never at real + * runtime, so gating on it disables the eager sync during the build only — + * `withX402`/`paymentProxyFromConfig` still default to `true` at real runtime. + */ +export const SYNC_FACILITATOR_ON_START = process.env.NEXT_PHASE !== "phase-production-build"; + +/** + * Shared EVM resource server for the new testnet endpoints. Registers both the `exact` + * and `upto` schemes for Base Sepolia — safe to share since scheme registration is keyed + * by (network, scheme name), the same way app/facilitator/index.ts already registers both + * `ExactEvmScheme` and `UptoEvmScheme` on `eip155:84532`. + */ +export const evmResourceServer = new x402ResourceServer(facilitatorClient) + .register(EVM_NETWORK, new ExactEvmScheme()) + .register(EVM_NETWORK, new UptoEvmScheme()); + +/** + * Shared SVM resource server for the new testnet endpoints. Only `exact` is registered + * — SVM `upto` is not offered yet: it needs persistent channel storage and a cron-driven + * rent cleanup worker (the in-memory, single-process approach used for EVM `upto` isn't + * viable for SVM's payment-channel rent model), so it's out of scope for this demo site + * until that infrastructure exists. + */ +export const svmResourceServer = new x402ResourceServer(facilitatorClient).register( + SVM_NETWORK, + new ExactSvmScheme(), +); + +/** + * Builds the EIP-3009 `exact` accept option — `ExactEvmScheme`'s default asset transfer + * method, so no `extra` override is needed. + * + * @returns The EIP-3009 payment option + */ +export function buildExactErc3009Accept(): PaymentOption { + return { + scheme: "exact", + network: EVM_NETWORK, + payTo: evmPayeeAddress, + price: EXACT_PRICE, + }; +} + +/** + * Builds the Permit2 `exact` accept option. Whether the client attempts a gasless + * EIP-2612 permit depends solely on whether the route advertises + * {@link EIP2612_EXTENSION} in `extensions` — not on this option's shape — so this same + * option is reused for both the "no extension" and "/eip2612" routes. + * + * @returns The Permit2 payment option + */ +export function buildExactPermit2Accept(): PaymentOption { + return { + scheme: "exact", + network: EVM_NETWORK, + payTo: evmPayeeAddress, + price: EXACT_PRICE, + extra: { assetTransferMethod: "permit2" }, + }; +} + +/** + * Builds the `upto` accept option (Permit2 is `UptoEvmScheme`'s only asset transfer + * method), priced at 2x {@link EXACT_PRICE}. + * + * @returns The upto payment option + */ +export function buildUptoAccept(): PaymentOption { + return { + scheme: "upto", + network: EVM_NETWORK, + payTo: evmPayeeAddress, + price: UPTO_PRICE, + }; +} + +/** + * Builds the SVM `exact` accept option (SVM exact has a single default asset transfer + * method, so no subshapes are needed). + * + * @returns The SVM exact payment option + */ +export function buildSvmExactAccept(): PaymentOption { + return { + scheme: "exact", + network: SVM_NETWORK, + payTo: svmPayeeAddress, + price: EXACT_PRICE, + }; +} + +/** + * Builds the JSON body returned instead of the SDK's default paywall HTML / empty JSON + * for an unpaid or failed request: short, human-readable advice on what's required to + * pay, plus whatever failure detail the SDK already produced. Deliberately does not + * repeat `accepts`/`extensions` — that's already in the `PAYMENT-REQUIRED` header real + * x402 clients read, and duplicating it here would just drift out of sync over time. + * + * @param response - The withX402-produced response being replaced + * @param advice - Route-specific advice on what mechanism/capability is required + * @returns A JSON-serializable body combining the advice with the available error detail + */ +function buildFailureBody(response: NextResponse, advice: string): Record { + // A settlement failure (payment was submitted but didn't settle) carries a + // PAYMENT-RESPONSE header with errorReason/errorMessage instead of PAYMENT-REQUIRED. + const settleHeader = response.headers.get("PAYMENT-RESPONSE"); + if (settleHeader) { + const settleResponse = decodePaymentResponseHeader(settleHeader); + return { + advice, + error: settleResponse.errorReason ?? "settlement_failed", + ...(settleResponse.errorMessage && { errorMessage: settleResponse.errorMessage }), + }; + } + + const requiredHeader = response.headers.get("PAYMENT-REQUIRED"); + if (requiredHeader) { + const paymentRequired = decodePaymentRequiredHeader(requiredHeader); + return { + advice, + ...(paymentRequired.error && { error: paymentRequired.error }), + }; + } + + return { advice }; +} + +/** + * Wraps a `withX402`-produced handler so that unpaid (402) and Permit2-allowance (412) + * responses always return the {@link buildFailureBody} JSON advice instead of the SDK's + * default browser paywall HTML or empty JSON body — these testnet endpoints are meant + * for programmatic/API clients, not the in-browser paywall UI. The real + * `PAYMENT-REQUIRED` / `PAYMENT-RESPONSE` headers (and all other headers) are preserved + * unchanged, so x402 client SDKs that read the header rather than the body are + * unaffected. Successful (settled) responses pass through untouched. + * + * @param wrapped - The `withX402`-wrapped GET handler to hijack the unpaid response of + * @param advice - Route-specific advice on what mechanism/capability is required + * @returns A GET handler that returns JSON advice instead of a paywall on 402/412 + */ +export function withJsonPaymentRequired( + wrapped: (request: NextRequest) => Promise, + advice: string, +): (request: NextRequest) => Promise { + return async (request: NextRequest): Promise => { + const response = await wrapped(request); + if (response.status !== 402 && response.status !== 412) { + return response; + } + + const explained = NextResponse.json(buildFailureBody(response, advice), { + status: response.status, + }); + response.headers.forEach((value, key) => { + if (key.toLowerCase() === "content-type") return; + explained.headers.set(key, value); + }); + return explained; + }; +} diff --git a/typescript/site/package.json b/typescript/site/package.json index dc4d24a194..73c7c870b0 100644 --- a/typescript/site/package.json +++ b/typescript/site/package.json @@ -7,12 +7,13 @@ "scripts": { "dev": "next dev", "build": "next build", - "build:vercel": "cd .. && pnpm install && cd site && pnpm -r --filter=site... run build", + "build:vercel": "cd .. && pnpm install --frozen-lockfile && NODE_OPTIONS=--max-old-space-size=4096 pnpm turbo run build --filter=site... --concurrency=1 && node scripts/verify-package-exports.mjs", "start": "next start", "format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"", "format:check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"", "lint": "eslint . --ext .ts --fix", - "lint:check": "eslint . --ext .ts" + "lint:check": "eslint . --ext .ts", + "test:e2e": "vitest run --config vitest.e2e.config.ts" }, "dependencies": { "@aptos-labs/ts-sdk": "^5.2.1", @@ -43,11 +44,14 @@ "@types/react-dom": "^19", "@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/parser": "^8.29.1", + "@x402/fetch": "workspace:*", "eslint": "^9.24.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsdoc": "^50.6.9", "eslint-plugin-prettier": "^5.2.6", "prettier": "3.5.2", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vite": "^6.2.6", + "vitest": "^3.2.7" } } diff --git a/typescript/site/proxy.ts b/typescript/site/proxy.ts index ba64f76a98..b9b9e49096 100644 --- a/typescript/site/proxy.ts +++ b/typescript/site/proxy.ts @@ -8,6 +8,7 @@ import { createPaywall } from "@x402/paywall"; import { evmPaywall } from "@x402/paywall/evm"; import { svmPaywall } from "@x402/paywall/svm"; import { avmPaywall } from "@x402/paywall/avm"; +import { SYNC_FACILITATOR_ON_START } from "@/lib/testnetProtectedResources"; const evmPayeeAddress = process.env.RESOURCE_EVM_ADDRESS as `0x${string}`; const svmPayeeAddress = process.env.RESOURCE_SVM_ADDRESS as string; @@ -89,6 +90,7 @@ const x402PaymentProxy = paymentProxyFromConfig( ], undefined, // paywallConfig paywall, // paywall provider + SYNC_FACILITATOR_ON_START, ); const geolocationProxy = async (req: NextRequest) => { diff --git a/typescript/site/test/e2e/README.md b/typescript/site/test/e2e/README.md new file mode 100644 index 0000000000..f08fe74964 --- /dev/null +++ b/typescript/site/test/e2e/README.md @@ -0,0 +1,52 @@ +# Site e2e tests + +Real end-to-end tests for the testnet `/protected/{evm,svm}/{exact,upto}...` routes +under `typescript/site`. Each test pays for a route using the real x402 client SDKs +(`@x402/fetch` plus the EVM/SVM `exact`/`upto` client schemes) against the site's own +Next.js server, spawned by the suite itself — no mocking, no separately-running dev +server required. + +## Run + +```bash +pnpm test:e2e +``` + +## Setup + +Add to `typescript/site/.env.local` (gitignored): + +```bash +# Base Sepolia. Needs ETH (gas) and USDC, and must already have approved the +# canonical Permit2 contract to spend that USDC — /exact/permit2 and +# /upto/permit2 (no-extension) require pre-approval and don't offer gasless +# approval themselves. +CLIENT_EVM_PRIVATE_KEY=0x... + +# Solana devnet. Needs SOL (fees/rent) and devnet USDC. Must be a *different* key +# from FACILITATOR_SVM_PRIVATE_KEY (in .env) — the exact SVM scheme rejects a +# payment where the fee payer and the payer are the same account. +CLIENT_SVM_PRIVATE_KEY=... + +# Optional; defaults to the public Base Sepolia RPC. Enables the gasless EIP-2612 +# permit path the /permit2/eip2612 routes are meant to exercise. +CLIENT_EVM_RPC_URL= +``` + +This app's own `RESOURCE_*`/`FACILITATOR_*` vars (already required to run the site at +all — see `typescript/site/.env`) are also required, since the spawned test server is +the real app. + +## Wallet safety + +Every test pays a small (testnet) amount, so repeated runs drain these wallets over +time — same caveat as [`e2e/README.md`](../../../../e2e/README.md#wallet-safety-warning). +Use dedicated testnet-only wallets; top them up from a faucet as needed. + +## Why not `e2e/`? + +The repo's root [`e2e/`](../../../../e2e/) suite is a catalog-driven harness for +testing SDK correctness generically across many client/server/facilitator/network +combinations. These tests instead validate one specific deployed app's specific +routes (its JSON 402/200 response shapes, its `upto` settlement override, etc.), so +they live next to the code they test rather than in that generic harness. diff --git a/typescript/site/test/e2e/protectedRoutes.e2e.test.ts b/typescript/site/test/e2e/protectedRoutes.e2e.test.ts new file mode 100644 index 0000000000..5bc0bcc445 --- /dev/null +++ b/typescript/site/test/e2e/protectedRoutes.e2e.test.ts @@ -0,0 +1,233 @@ +import { base58 } from "@scure/base"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { decodePaymentRequiredHeader } from "@x402/core/http"; +import { convertToTokenAmount } from "@x402/core/utils"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { UptoEvmScheme } from "@x402/evm/upto/client"; +import { wrapFetchWithPayment, x402Client, x402HTTPClient } from "@x402/fetch"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { startTestServer, stopTestServer } from "./server"; + +/** USDC on Base Sepolia and Solana devnet. */ +const USDC_DECIMALS = 6; +/** Exact subroutes advertise and settle this amount ($0.001 USDC). */ +const EXACT_AMOUNT = convertToTokenAmount("0.001", USDC_DECIMALS); +/** Upto subroutes authorize this amount ($0.002 USDC) and settle half of it. */ +const UPTO_AUTHORIZED_AMOUNT = convertToTokenAmount("0.002", USDC_DECIMALS); +/** `GET /protected` keeps its original price. Subroutes under `/protected/*` do not. */ +const PROTECTED_AMOUNT = convertToTokenAmount("0.01", USDC_DECIMALS); + +if (BigInt(UPTO_AUTHORIZED_AMOUNT) !== BigInt(EXACT_AMOUNT) * 2n) { + throw new Error("upto authorization must be exactly twice the exact settlement amount"); +} + +/** + * End-to-end tests for the testnet `/protected/{evm,svm}/{exact,upto}...` endpoints + * added under `typescript/site`: each test pays for a route using the real x402 + * client SDKs (`@x402/fetch` plus the EVM/SVM `exact`/`upto` client schemes) against + * the site's own Next.js server — exercising the full client -> route handler -> + * facilitator -> onchain settle path end to end, not mocked. + * + * Requires testnet-funded wallets: + * - `CLIENT_EVM_PRIVATE_KEY`: needs Base Sepolia ETH (gas) and USDC, and must already + * have approved the canonical Permit2 contract to spend that USDC — the + * `/exact/permit2` and `/upto/permit2` (no-extension) routes require pre-approval + * and do not offer gasless approval themselves. + * - `CLIENT_SVM_PRIVATE_KEY`: needs Solana devnet SOL (fees/rent) and devnet USDC. + * Must be a *different* key from the testnet facilitator's own SVM signer — the + * exact SVM scheme rejects a payment where the fee payer and the payer are the + * same account. + * - `CLIENT_EVM_RPC_URL`: optional; defaults to the public Base Sepolia RPC. Enables + * the gasless EIP-2612 permit path the `/permit2/eip2612` routes are meant to + * exercise; without it those routes fall back to the plain Permit2 flow (same as + * the no-extension routes), same as any real client lacking RPC read access would. + * + * Each test pays a small (testnet) amount, so repeated runs will drain these wallets + * over time — same caveat as `e2e/README.md`'s "Wallet Safety Warning". Not run as + * part of the default `pnpm test`; run explicitly with `pnpm test:e2e`. + */ + +const EVM_PRIVATE_KEY = process.env.CLIENT_EVM_PRIVATE_KEY as `0x${string}` | undefined; +const SVM_PRIVATE_KEY = process.env.CLIENT_SVM_PRIVATE_KEY; +const EVM_RPC_URL = process.env.CLIENT_EVM_RPC_URL ?? "https://sepolia.base.org"; + +if (!EVM_PRIVATE_KEY || !SVM_PRIVATE_KEY) { + throw new Error( + "CLIENT_EVM_PRIVATE_KEY and CLIENT_SVM_PRIVATE_KEY environment variables are required " + + "for these testnet e2e tests. See this file's header comment for requirements.", + ); +} + +let baseUrl: string; +let fetchWithPayment: typeof fetch; +let httpClient: x402HTTPClient; + +beforeAll(async () => { + baseUrl = await startTestServer(); + + const evmSigner = privateKeyToAccount(EVM_PRIVATE_KEY); + const svmSigner = await createKeyPairSignerFromBytes(base58.decode(SVM_PRIVATE_KEY)); + + const client = new x402Client(); + client.setSpendControls({ maxAmountPerPayment: "$1" }); + client.register("eip155:*", new ExactEvmScheme(evmSigner, { rpcUrl: EVM_RPC_URL })); + client.register("eip155:*", new UptoEvmScheme(evmSigner, { rpcUrl: EVM_RPC_URL })); + client.register("solana:*", new ExactSvmScheme(svmSigner)); + + fetchWithPayment = wrapFetchWithPayment(fetch, client); + httpClient = new x402HTTPClient(client); +}); + +afterAll(async () => { + await stopTestServer(); +}); + +/** + * Pays for and fetches a protected route, asserting the payment actually settled. + * + * @param path - Route path (e.g. `/protected/evm/exact`) + * @returns The parsed response body and decoded settlement header + */ +async function payAndFetch(path: string) { + const response = await fetchWithPayment(`${baseUrl}${path}`); + const result = await httpClient.processResponse(response); + expect(result.status, `${path} did not return 200: ${JSON.stringify(result.body)}`).toBe(200); + expect(result.paymentStatus).toBe("settled"); + return result; +} + +/** + * Asserts a settled response's actual on-chain settlement amount — relevant for the + * `upto` scheme, where it can differ from the authorized maximum. Exact settlements + * omit `amount` and transfer the full advertised figure instead. + * + * @param header - The decoded payment header from `x402HTTPClient.processResponse` + * @param expectedAmount - Expected settled amount, in atomic token units + */ +function expectSettledAmount(header: unknown, expectedAmount: string): void { + expect(header).toMatchObject({ success: true, amount: expectedAmount }); +} + +/** + * Asserts an unpaid request advertises `expectedAmount` on every accept option. + * Exact routes settle this figure in full; `upto` routes authorize it and may settle less. + * + * @param path - Route path (e.g. `/protected/evm/exact`) + * @param expectedAmount - Expected advertised amount, in atomic token units + */ +async function expectAdvertisedAmount(path: string, expectedAmount: string): Promise { + const response = await fetch(`${baseUrl}${path}`); + expect(response.status, `${path} unpaid status`).toBe(402); + const encoded = response.headers.get("PAYMENT-REQUIRED"); + expect(encoded, `${path} missing PAYMENT-REQUIRED`).toBeTruthy(); + const required = decodePaymentRequiredHeader(encoded!); + expect(required.accepts.length, `${path} has no accept options`).toBeGreaterThan(0); + for (const accept of required.accepts) { + expect(accept.amount, `${path} ${accept.scheme} on ${accept.network}`).toBe(expectedAmount); + } +} + +describe("unpaid request", () => { + it("GET /protected still advertises $0.01", async () => { + await expectAdvertisedAmount("/protected", PROTECTED_AMOUNT); + }); + + it("returns the advice JSON body, not the paywall, on 402", async () => { + const response = await fetch(`${baseUrl}/protected/evm/exact`); + expect(response.status).toBe(402); + expect(response.headers.get("content-type")).toContain("application/json"); + const body = await response.json(); + expect(body).toHaveProperty("advice"); + expect(typeof body.advice).toBe("string"); + }); +}); + +describe("EVM exact", () => { + it("GET /protected/evm/exact settles $0.001 (erc3009 or permit2)", async () => { + await expectAdvertisedAmount("/protected/evm/exact", EXACT_AMOUNT); + const { body } = await payAndFetch("/protected/evm/exact"); + expect(body).toMatchObject({ ok: true, caip2Family: "evm", scheme: "exact" }); + }); + + it("GET /protected/evm/exact/erc3009 settles $0.001", async () => { + await expectAdvertisedAmount("/protected/evm/exact/erc3009", EXACT_AMOUNT); + const { body } = await payAndFetch("/protected/evm/exact/erc3009"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "erc3009", + }); + }); + + it("GET /protected/evm/exact/permit2 settles $0.001 (requires pre-approved Permit2 allowance)", async () => { + await expectAdvertisedAmount("/protected/evm/exact/permit2", EXACT_AMOUNT); + const { body } = await payAndFetch("/protected/evm/exact/permit2"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "permit2", + }); + }); + + it("GET /protected/evm/exact/permit2/eip2612 settles $0.001", async () => { + await expectAdvertisedAmount("/protected/evm/exact/permit2/eip2612", EXACT_AMOUNT); + const { body } = await payAndFetch("/protected/evm/exact/permit2/eip2612"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "exact", + assetTransferMethod: "permit2", + extension: "eip2612GasSponsoring", + }); + }); +}); + +describe("EVM upto", () => { + it("GET /protected/evm/upto authorizes $0.002 and settles $0.001", async () => { + await expectAdvertisedAmount("/protected/evm/upto", UPTO_AUTHORIZED_AMOUNT); + const { body, header } = await payAndFetch("/protected/evm/upto"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "upto", + settledPercentOfAuthorized: "50%", + }); + expectSettledAmount(header, EXACT_AMOUNT); + }); + + it("GET /protected/evm/upto/permit2 authorizes $0.002 and settles $0.001", async () => { + await expectAdvertisedAmount("/protected/evm/upto/permit2", UPTO_AUTHORIZED_AMOUNT); + const { body, header } = await payAndFetch("/protected/evm/upto/permit2"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "upto", + settledPercentOfAuthorized: "50%", + }); + expectSettledAmount(header, EXACT_AMOUNT); + }); + + it("GET /protected/evm/upto/permit2/eip2612 authorizes $0.002 and settles $0.001", async () => { + await expectAdvertisedAmount("/protected/evm/upto/permit2/eip2612", UPTO_AUTHORIZED_AMOUNT); + const { body, header } = await payAndFetch("/protected/evm/upto/permit2/eip2612"); + expect(body).toMatchObject({ + ok: true, + caip2Family: "evm", + scheme: "upto", + settledPercentOfAuthorized: "50%", + }); + expectSettledAmount(header, EXACT_AMOUNT); + }); +}); + +describe("SVM exact", () => { + it("GET /protected/svm/exact settles $0.001", async () => { + await expectAdvertisedAmount("/protected/svm/exact", EXACT_AMOUNT); + const { body } = await payAndFetch("/protected/svm/exact"); + expect(body).toMatchObject({ ok: true, caip2Family: "svm", scheme: "exact" }); + }); +}); diff --git a/typescript/site/test/e2e/server.ts b/typescript/site/test/e2e/server.ts new file mode 100644 index 0000000000..a21ff5cf76 --- /dev/null +++ b/typescript/site/test/e2e/server.ts @@ -0,0 +1,91 @@ +import { type ChildProcess, spawn } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const PORT = Number(process.env.E2E_PORT ?? 3100); +export const E2E_BASE_URL = `http://localhost:${PORT}`; + +const READY_PATH = "/protected"; +const READY_TIMEOUT_MS = 30_000; +const READY_POLL_INTERVAL_MS = 250; + +// `next dev` rewrites this file in place (reformatting it and appending a dev-server +// type-check include entry) as a side effect of starting up — completely unrelated to +// the route handlers under test. Snapshot and restore it so `pnpm test:e2e` never +// leaves a stray tsconfig.json diff behind. +const TSCONFIG_PATH = join(process.cwd(), "tsconfig.json"); + +let serverProcess: ChildProcess | undefined; +let tsconfigSnapshot: string | undefined; + +/** + * Starts the site's own Next.js dev server on a dedicated port (distinct from the + * default 3000, so this doesn't collide with a developer's own `pnpm dev`) and waits + * for it to respond, so the e2e suite is self-contained — no need to have the site + * already running. Uses `next dev`, not a production build: a real production build + * isn't needed to exercise the route handlers under test, and Turbopack's + * compile-on-first-request cost is paid once, during the readiness poll below. + * + * @returns The server's base URL + */ +export async function startTestServer(): Promise { + tsconfigSnapshot = readFileSync(TSCONFIG_PATH, "utf-8"); + + const nextBin = join(process.cwd(), "node_modules/.bin/next"); + const facilitatorUrl = `${E2E_BASE_URL}/facilitator`; + serverProcess = spawn(nextBin, ["dev", "-p", String(PORT)], { + cwd: process.cwd(), + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + // The route handlers call this app's own /facilitator route to verify/settle + // payments; .env's default points at port 3000, which isn't this dedicated + // test port, so override it to match wherever this server actually listens. + FACILITATOR_URL: facilitatorUrl, + NEXT_PUBLIC_FACILITATOR_URL: facilitatorUrl, + }, + }); + + // Surface server-side errors (e.g. a missing required env var) instead of letting + // the readiness poll below time out with no explanation. + let startupOutput = ""; + serverProcess.stdout?.on("data", chunk => (startupOutput += String(chunk))); + serverProcess.stderr?.on("data", chunk => (startupOutput += String(chunk))); + + const deadline = Date.now() + READY_TIMEOUT_MS; + while (Date.now() < deadline) { + try { + await fetch(`${E2E_BASE_URL}${READY_PATH}`); + return E2E_BASE_URL; + } catch { + await new Promise(resolve => setTimeout(resolve, READY_POLL_INTERVAL_MS)); + } + } + + await stopTestServer(); + throw new Error( + `Site did not become ready on ${E2E_BASE_URL} within ${READY_TIMEOUT_MS}ms. ` + + `Server output:\n${startupOutput}`, + ); +} + +/** + * Stops the server started by {@link startTestServer}, if any. + * + * @returns A promise that resolves once the process has exited + */ +export async function stopTestServer(): Promise { + if (tsconfigSnapshot !== undefined) { + writeFileSync(TSCONFIG_PATH, tsconfigSnapshot); + tsconfigSnapshot = undefined; + } + + const proc = serverProcess; + if (!proc) return; + serverProcess = undefined; + + await new Promise(resolve => { + proc.once("exit", () => resolve()); + proc.kill(); + }); +} diff --git a/typescript/site/vitest.e2e.config.ts b/typescript/site/vitest.e2e.config.ts new file mode 100644 index 0000000000..c7e1393e7c --- /dev/null +++ b/typescript/site/vitest.e2e.config.ts @@ -0,0 +1,21 @@ +import { loadEnv } from "vite"; +import { defineConfig } from "vitest/config"; + +export default defineConfig(({ mode }) => ({ + test: { + // Load CLIENT_EVM_PRIVATE_KEY / CLIENT_SVM_PRIVATE_KEY / CLIENT_EVM_RPC_URL (plus + // this app's own RESOURCE_*/FACILITATOR_* vars, inherited by the spawned `next + // dev` child process below) from .env / .env.local, same convention the + // mechanism packages' vitest.integration.config.ts use. + env: loadEnv(mode, process.cwd(), ""), + include: ["test/e2e/**/*.test.ts"], + // Every test in this suite shares the same testnet client wallets and pays a real + // (small) amount per request; running them concurrently would race nonces/balances + // for no benefit, since the suite is already fast (a handful of testnet payments). + fileParallelism: false, + // Real chain settlement (plus the dev server's first-request compile) can take + // several seconds; the default vitest timeout is too tight for that. + testTimeout: 60_000, + hookTimeout: 60_000, + }, +}));