diff --git a/e2e/README.md b/e2e/README.md index a22c077208..df8141b09f 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -236,7 +236,7 @@ You need **three separate Stellar accounts** for e2e tests (client, server, faci #### Cardano Preprod -The Cardano e2e endpoint is paid in **lovelace** (native tADA), so you only need test ADA — no stablecoin trustline or sourcing. +The Cardano e2e endpoints are paid in **lovelace** (native tADA) by default, so you only need test ADA — no stablecoin trustline or sourcing. 1. Create a preprod wallet to obtain a **24-word mnemonic** and an `addr_test1...` address — either with a CIP-30 wallet (Eternl / Lace in **preprod** mode) or programmatically via `PrivateKey.generateMnemonic()` from `@evolution-sdk/evolution`. You only need a **client** mnemonic (`CLIENT_CARDANO_MNEMONIC`); the server just needs an address (`SERVER_CARDANO_ADDRESS`) — the client's `addr_test1...` works. `FACILITATOR_CARDANO_MNEMONIC` is **optional**: the facilitator only broadcasts the client's signed transaction, so it runs provider-only when no mnemonic is set. 2. Fund the **client** wallet with test ADA from the [Cardano testnets faucet](https://docs.cardano.org/cardano-testnets/tools/faucet/) (select **Preprod**); ~10 tADA is plenty. Only the client needs funds — it builds, signs, and pays the fee; the facilitator only broadcasts the transaction. @@ -244,6 +244,18 @@ The Cardano e2e endpoint is paid in **lovelace** (native tADA), so you only need > **Note:** Run Cardano standalone via the chain-agnostic apps and the `cardano` family filter: `pnpm test --clients=fetch --servers=express --facilitators=typescript --families=cardano`. Without `--families=cardano` the harness expands to every protocol family those apps support and requires all of their credentials (EVM, SVM, etc.). +###### Native-token (tUSDM) run + +The asset is a config axis, not a separate endpoint: the same `default` / `masumi` / `script` methods run against a native token when `SERVER_CARDANO_ASSET` is set to a `policyId.assetNameHex` unit. This is the only place the token paths are exercised end to end (structural lovelace on the escrow output, exact token matching), so run it whenever the Masumi or signer funding logic changes: + +```bash +SERVER_CARDANO_ASSET=e675b46e4d2242c991a8932a99db3044e80515ae14b4c4ccf6b3f4c9.0014df10745553444d \ +SERVER_CARDANO_AMOUNT=1000000 \ +pnpm test --clients=fetch --servers=express --facilitators=typescript --families=cardano +``` + +The client wallet must hold **tUSDM** on preprod in addition to a few tADA — a native-token lock still carries structural lovelace (post-result min-UTXO plus collateral), which the client signer funds itself. + ##### TON testnet funding for TVM e2e and examples - **Testnet TON**: use [@testgiver_ton_bot](https://t.me/testgiver_ton_bot) to fund the facilitator and payer wallets with TON for relay fees. The facilitator wallet must hold **at least 1.1 TON** before running tests. diff --git a/e2e/clients/axios/test.config.json b/e2e/clients/axios/test.config.json index 8249770c14..5f82bab1bf 100644 --- a/e2e/clients/axios/test.config.json +++ b/e2e/clients/axios/test.config.json @@ -2,7 +2,7 @@ "name": "axios", "type": "client", "language": "typescript", - "protocolFamilies": ["evm", "svm", "avm", "aptos", "hedera", "keeta", "near", "stellar", "tvm", "ccd", "cardano", "xrpl"], + "protocolFamilies": ["evm", "svm", "avm", "aptos", "hedera", "keeta", "near", "stellar", "tvm", "ccd", "xrpl", "cardano"], "x402Versions": [1, 2], "schemes": ["exact", "upto", "batch-settlement"], "evm": { diff --git a/e2e/clients/fetch/test.config.json b/e2e/clients/fetch/test.config.json index 62ca4bd45c..a8d92a4b7f 100644 --- a/e2e/clients/fetch/test.config.json +++ b/e2e/clients/fetch/test.config.json @@ -2,7 +2,7 @@ "name": "fetch", "type": "client", "language": "typescript", - "protocolFamilies": ["evm", "svm", "avm", "aptos", "hedera", "keeta", "near", "stellar", "tvm", "ccd", "cardano", "xrpl"], + "protocolFamilies": ["evm", "svm", "avm", "aptos", "hedera", "keeta", "near", "stellar", "tvm", "ccd", "xrpl", "cardano"], "x402Versions": [1, 2], "schemes": ["exact", "upto", "batch-settlement"], "evm": { diff --git a/e2e/facilitators/typescript/test.config.json b/e2e/facilitators/typescript/test.config.json index 8e3b5efe33..8d7f85338c 100644 --- a/e2e/facilitators/typescript/test.config.json +++ b/e2e/facilitators/typescript/test.config.json @@ -13,35 +13,21 @@ "tvm", "near", "ccd", - "cardano", - "xrpl" - ], - "x402Versions": [ - 1, - 2 - ], - "schemes": [ - "exact", - "upto", - "batch-settlement" + "xrpl", + "cardano" ], + "x402Versions": [1, 2], + "schemes": ["exact", "upto", "batch-settlement"], "extensions": [ "bazaar", "eip2612GasSponsoring", "erc20ApprovalGasSponsoring" ], "evm": { - "assetTransferMethods": [ - "eip3009", - "permit2" - ] + "assetTransferMethods": ["eip3009", "permit2"] }, "environment": { - "required": [ - "PORT", - "EVM_PRIVATE_KEY", - "SVM_PRIVATE_KEY" - ], + "required": ["PORT", "EVM_PRIVATE_KEY", "SVM_PRIVATE_KEY"], "optional": [ "AVM_PRIVATE_KEY", "APTOS_PRIVATE_KEY", diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index c3347bcc2b..5d4081c8fb 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -1319,9 +1319,15 @@ importers: '@evolution-sdk/evolution': specifier: ^0.5.9 version: 0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 '@x402/core': specifier: workspace:~ version: link:../../core + lz-string: + specifier: ^1.5.0 + version: 1.5.0 devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -10021,6 +10027,10 @@ packages: lute-connect@1.7.0: resolution: {integrity: sha512-/eXb2/c/xltKyVEVWchd1QZB6F0fvgXwVIqXDQWeJ9unPo0kMMbtuLkeb1v4Kr1lffxX8uGnb+8kAMYjczUASg==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} @@ -22638,6 +22648,8 @@ snapshots: lute-connect@1.7.0: {} + lz-string@1.5.0: {} + magic-string@0.30.19: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 diff --git a/e2e/servers/express/index.ts b/e2e/servers/express/index.ts index 576e985a69..5f301edda6 100644 --- a/e2e/servers/express/index.ts +++ b/e2e/servers/express/index.ts @@ -12,7 +12,7 @@ import { KEETA_TESTNET_CAIP2 } from "@x402/keeta"; import { ExactKeetaScheme } from "@x402/keeta/exact/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; import { ExactCardanoScheme } from "@x402/cardano/exact/server"; -import { masumiContractAddress, getDefaultUsdmAsset } from "@x402/cardano"; +import { issueMasumiRequirements, toMasumiSellerSigner } from "@x402/cardano"; import { ExactTvmScheme } from "@x402/tvm/exact/server"; import { ExactNearScheme } from "@x402/near/exact/server"; import type { XrplAssetTransferMethod } from "@x402/xrpl"; @@ -59,6 +59,13 @@ const HEDERA_PAYEE_ADDRESS = process.env.HEDERA_PAYEE_ADDRESS as string | undefi const KEETA_PAYEE_ADDRESS = process.env.KEETA_PAYEE_ADDRESS as string | undefined; const STELLAR_PAYEE_ADDRESS = process.env.STELLAR_PAYEE_ADDRESS as string | undefined; const CARDANO_PAYEE_ADDRESS = process.env.CARDANO_PAYEE_ADDRESS as string | undefined; +// Asset the Cardano endpoints charge in. Defaults to lovelace (tADA) so the e2e +// is faucet-fundable; set CARDANO_ASSET to a `policyId.assetNameHex` unit (e.g. +// getDefaultUsdmAsset(CARDANO_NETWORK)) to run the same methods against a native +// token. CARDANO_AMOUNT is in the asset's smallest unit and must clear the +// masumi escrow's min-UTXO when paying lovelace. +const CARDANO_ASSET = process.env.CARDANO_ASSET || "lovelace"; +const CARDANO_AMOUNT = process.env.CARDANO_AMOUNT || "5000000"; // Script (assetTransferMethod=script) fixture: a minimal always-succeeds Plutus // V3 validator and its enterprise (preprod/preview) script address. The // facilitator reconstructs this address from the script in `extra` and verifies @@ -81,16 +88,65 @@ const CARDANO_DISCOVERY = declareDiscoveryExtension({ }, }, }); -// Masumi lock deadlines for the demo, future-relative so the anchored tx TTL -// (invalidHereafter = pay_by_time) stays valid across a run. A real integration -// takes these from the Masumi purchase instead of a fixed offset at startup. -const CARDANO_MASUMI_BASE_MS = Date.now(); -const CARDANO_MASUMI_TIMES = { - payByTime: (CARDANO_MASUMI_BASE_MS + 30 * 60_000).toString(), - submitResultTime: (CARDANO_MASUMI_BASE_MS + 60 * 60_000).toString(), - unlockTime: (CARDANO_MASUMI_BASE_MS + 90 * 60_000).toString(), - externalDisputeUnlockTime: (CARDANO_MASUMI_BASE_MS + 120 * 60_000).toString(), +// How long a Cardano payment stays valid. The Masumi client anchors the tx TTL +// to pay_by_time, and the facilitator refuses a TTL further ahead than +// maxTimeoutSeconds, so pay_by_time cannot exceed issuance + this value. +const CARDANO_MAX_TIMEOUT_SECONDS = 600; +// Canonical block inclusion is the bar for the e2e: the spec default of 1 would +// make every Cardano scenario wait an extra ~20s block for no added signal. +const CARDANO_CONFIRMATION_POLICY = { l1Confirmations: 0 }; +// The seller signs the Masumi terms with its selling wallet. The escrow pays +// this address, so a real deployment MUST set CARDANO_SELLER_MNEMONIC; the +// well-known test phrase below only keeps the e2e endpoint self-contained. The +// key needs no funds — it only authorizes the terms. +const CARDANO_TEST_SELLER_MNEMONIC = + "test test test test test test test test test test test junk"; +const CARDANO_SELLER = CARDANO_PAYEE_ADDRESS + ? toMasumiSellerSigner({ + mnemonic: process.env.CARDANO_SELLER_MNEMONIC || CARDANO_TEST_SELLER_MNEMONIC, + network: CARDANO_NETWORK, + }) + : undefined; +/** + * Issues one Masumi offer. A spec-conformant Masumi 402 carries a request + * commitment and a seller signature over termsDigest, so it must be issued + * rather than hand-written, and each issuance draws a fresh sellerNonce. + * + * The lock deadlines clear the spec's minimum intervals: pay_by + 5min <= + * submit_result, submit_result + 15min <= unlock, unlock + 15min <= dispute. + * + * @returns The issued requirements: payTo (the escrow address), the price, and + * the `extra` block carrying the commitment, terms and authorization. + */ +const issueCardanoMasumiOffer = async () => { + const payByMs = Date.now() + CARDANO_MAX_TIMEOUT_SECONDS * 1000; + return issueMasumiRequirements({ + network: CARDANO_NETWORK, + asset: CARDANO_ASSET, + amount: CARDANO_AMOUNT, + maxTimeoutSeconds: CARDANO_MAX_TIMEOUT_SECONDS, + sellerAddress: CARDANO_SELLER!.sellerAddress, + signTerms: CARDANO_SELLER!.signTerms, + commitment: [ + { + name: "parameters", + canonicalization: "jcs", + mediaType: "application/json", + content: { endpoint: "/exact/cardano/masumi" }, + }, + ], + payByTime: payByMs.toString(), + submitResultTime: (payByMs + 5 * 60_000).toString(), + unlockTime: (payByMs + 20 * 60_000).toString(), + externalDisputeUnlockTime: (payByMs + 35 * 60_000).toString(), + settlementPolicy: "l1", + confirmationPolicy: CARDANO_CONFIRMATION_POLICY, + }); }; +// The offer currently on the wire. termsDigest binds to exactly one settled +// transaction, so an offer is one-shot: a second payer under the same terms is +// a replay the facilitator refuses. Re-issued per unpaid request below. +let CARDANO_MASUMI_REQUIREMENTS = CARDANO_SELLER ? await issueCardanoMasumiOffer() : undefined; const TVM_PAYEE_ADDRESS = process.env.TVM_PAYEE_ADDRESS as string | undefined; const NEAR_NETWORK = (process.env.NEAR_NETWORK || "near:testnet") as `${string}:${string}`; const NEAR_PAYEE_ADDRESS = process.env.NEAR_PAYEE_ADDRESS as string | undefined; @@ -140,10 +196,18 @@ if (!facilitatorUrl) { const app = express(); // Create facilitator clients (mock facilitator as fallback for startup validation) -const facilitatorClients = [new HTTPFacilitatorClient({ url: facilitatorUrl })]; +// Cardano settles on ~20-second blocks, so its `settle()` cannot finish inside +// the 30s facilitator-client default. Raising the ceiling costs nothing on the +// fast chains — they still return as soon as they are done. +const FACILITATOR_TIMEOUT_MS = 180_000; +const facilitatorClients = [ + new HTTPFacilitatorClient({ url: facilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), +]; const mockFacilitatorUrl = process.env.MOCK_FACILITATOR_URL; if (mockFacilitatorUrl) { - facilitatorClients.push(new HTTPFacilitatorClient({ url: mockFacilitatorUrl })); + facilitatorClients.push( + new HTTPFacilitatorClient({ url: mockFacilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), + ); } // Create x402 resource server @@ -305,6 +369,19 @@ app.use("/exact/cardano", (req, res, next) => { next(); }); +/** + * Re-issues the Masumi offer ahead of each unpaid request, so two clients + * hitting this endpoint never share a termsDigest. A request carrying + * PAYMENT-SIGNATURE is the paid retry of an offer the client was already + * quoted and must be matched against that same offer, so it is left alone. + */ +app.use("/exact/cardano/masumi", async (req, _res, next) => { + if (CARDANO_SELLER && !req.headers["payment-signature"]) { + CARDANO_MASUMI_REQUIREMENTS = await issueCardanoMasumiOffer(); + } + next(); +}); + /** * Pre-middleware guard for optional TVM endpoint * Returns 501 Not Implemented if TVM is not configured @@ -785,69 +862,45 @@ app.use( : {}), ...(CARDANO_PAYEE_ADDRESS ? { - // One endpoint per Cardano assetTransferMethod; all pay lovelace - // (tADA) so the e2e is faucet-fundable (preprod USDM is not). + // One endpoint per Cardano assetTransferMethod. The asset is a config + // axis (CARDANO_ASSET), not a separate endpoint, so the same three + // methods run unchanged against tADA or a native token. "GET /exact/cardano/default": { accepts: { payTo: CARDANO_PAYEE_ADDRESS!, scheme: "exact", - price: { amount: "2000000", asset: "lovelace" }, - network: CARDANO_NETWORK, - extra: { assetTransferMethod: "default" }, - }, - extensions: { ...CARDANO_DISCOVERY }, - }, - "GET /exact/cardano/masumi": { - accepts: { - // Locks ADA into the vested_pay escrow (Web3CardanoV2): payTo is - // the escrow script address, seller is the payee. - payTo: masumiContractAddress(CARDANO_NETWORK), - scheme: "exact", - price: { amount: "5000000", asset: "lovelace" }, + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, extra: { - assetTransferMethod: "masumi", - paymentType: "Web3CardanoV2", - // Escrow address for this deployment (must equal payTo). - contractAddress: masumiContractAddress(CARDANO_NETWORK), - sellerAddress: CARDANO_PAYEE_ADDRESS!, - agentIdentifier: "deadbeefdeadbeefdeadbeefdeadbeef", - inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - collateralReturnLovelace: "0", - // Purchase-bound identifiers; real values come from the Masumi - // purchase. Fixed placeholders keep the e2e lock deterministic. - referenceKey: "aa".repeat(32), - referenceSignature: "bb".repeat(64), - sellerNonce: "cc".repeat(32), - identifierFromPurchaser: "dd".repeat(32), - ...CARDANO_MASUMI_TIMES, + assetTransferMethod: "default", + confirmationPolicy: CARDANO_CONFIRMATION_POLICY, }, }, extensions: { ...CARDANO_DISCOVERY }, }, - "GET /exact/cardano/masumi-usdm": { + "GET /exact/cardano/masumi": { accepts: { - // Masumi lock paying a native token (tUSDM) instead of ADA. The - // escrow output also carries structural lovelace (min-UTXO + - // collateral), which the client signer funds automatically. The - // buyer wallet must hold tUSDM (preprod) plus ~4 ADA for the lock. - payTo: masumiContractAddress(CARDANO_NETWORK), + // Locks into the vested_pay escrow (Web3CardanoV2). payTo is the + // escrow script address the verifier re-derives from the + // deployment parameters — it is never hand-supplied. For a + // native-token lock the escrow output also carries structural + // lovelace (min-UTXO + collateral) the client computes itself. + payTo: CARDANO_MASUMI_REQUIREMENTS!.payTo, scheme: "exact", - price: { amount: "1000000", asset: getDefaultUsdmAsset(CARDANO_NETWORK) }, + price: { + amount: CARDANO_MASUMI_REQUIREMENTS!.amount, + asset: CARDANO_MASUMI_REQUIREMENTS!.asset, + }, network: CARDANO_NETWORK, - extra: { - assetTransferMethod: "masumi", - paymentType: "Web3CardanoV2", - contractAddress: masumiContractAddress(CARDANO_NETWORK), - sellerAddress: CARDANO_PAYEE_ADDRESS!, - agentIdentifier: "deadbeefdeadbeefdeadbeefdeadbeef", - inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - collateralReturnLovelace: "0", - referenceKey: "aa".repeat(32), - referenceSignature: "bb".repeat(64), - sellerNonce: "cc".repeat(32), - identifierFromPurchaser: "dd".repeat(32), - ...CARDANO_MASUMI_TIMES, + maxTimeoutSeconds: CARDANO_MAX_TIMEOUT_SECONDS, + // Carries the request commitment, the seller-signed terms, the + // CIP-8 authorization over termsDigest and the compatibility + // identifier. A getter, because the middleware reads it per + // request and the pre-middleware above rotates the offer between + // 402s; the paid retry sees the same block it was quoted, since + // regenerating it would change termsDigest. + get extra() { + return CARDANO_MASUMI_REQUIREMENTS!.extra; }, }, extensions: { ...CARDANO_DISCOVERY }, @@ -858,10 +911,11 @@ app.use( // the script descriptor below and verifies it matches. payTo: CARDANO_SCRIPT_ADDRESS, scheme: "exact", - price: { amount: "2000000", asset: "lovelace" }, + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, extra: { assetTransferMethod: "script", + confirmationPolicy: CARDANO_CONFIRMATION_POLICY, script: { type: "plutusV3", code: CARDANO_SCRIPT_CODE }, // Optional inline datum (CBOR hex) attached to the payTo output. // A real contract declares whatever datum it needs; the client @@ -1132,7 +1186,6 @@ if (CARDANO_PAYEE_ADDRESS) { [ "/exact/cardano/default", "/exact/cardano/masumi", - "/exact/cardano/masumi-usdm", "/exact/cardano/script", ], (req, res) => { @@ -1253,7 +1306,6 @@ app.listen(parseInt(PORT), () => { ║ • GET /exact/stellar (Stellar) ║ ║ • GET /exact/cardano/default (Cardano) ║ ║ • GET /exact/cardano/masumi (Cardano) ║ -║ • GET /exact/cardano/masumi-usdm (Cardano) ║ ║ • GET /exact/cardano/script (Cardano) ║ ║ • GET /exact/tvm (TVM) ║ ║ • GET /health (no payment required) ║ diff --git a/e2e/servers/express/test.config.json b/e2e/servers/express/test.config.json index a699825c63..dc7f3e15d1 100644 --- a/e2e/servers/express/test.config.json +++ b/e2e/servers/express/test.config.json @@ -197,13 +197,6 @@ "requiresPayment": true, "protocolFamily": "cardano" }, - { - "path": "/exact/cardano/masumi-usdm", - "method": "GET", - "description": "Protected Cardano endpoint using the masumi assetTransferMethod paying a native token (tUSDM)", - "requiresPayment": true, - "protocolFamily": "cardano" - }, { "path": "/exact/cardano/script", "method": "GET", diff --git a/e2e/servers/fastify/index.ts b/e2e/servers/fastify/index.ts index f775ce7a72..271feddb8d 100644 --- a/e2e/servers/fastify/index.ts +++ b/e2e/servers/fastify/index.ts @@ -45,6 +45,9 @@ const AVM_NETWORK = (process.env.AVM_NETWORK || const KEETA_NETWORK = (process.env.KEETA_NETWORK || KEETA_TESTNET_CAIP2) as `${string}:${string}`; const STELLAR_NETWORK = (process.env.STELLAR_NETWORK || "stellar:testnet") as `${string}:${string}`; const CARDANO_NETWORK = (process.env.CARDANO_NETWORK || "cardano:preprod") as `${string}:${string}`; +// Canonical block inclusion is the bar for the e2e: the spec default of 1 would +// make every Cardano scenario wait an extra ~20s block for no added signal. +const CARDANO_CONFIRMATION_POLICY = { l1Confirmations: 0 }; const TVM_NETWORK = (process.env.TVM_NETWORK || "tvm:-3") as `${string}:${string}`; const CCD_NETWORK = (process.env.CCD_NETWORK || "ccd:4221332d34e1694168c2a0c0b3fd0f27") as `${string}:${string}`; @@ -59,6 +62,11 @@ const HEDERA_PAYEE_ADDRESS = process.env.HEDERA_PAYEE_ADDRESS as string | undefi const KEETA_PAYEE_ADDRESS = process.env.KEETA_PAYEE_ADDRESS as string | undefined; const STELLAR_PAYEE_ADDRESS = process.env.STELLAR_PAYEE_ADDRESS as string | undefined; const CARDANO_PAYEE_ADDRESS = process.env.CARDANO_PAYEE_ADDRESS as string | undefined; +// Asset the Cardano endpoint charges in; defaults to lovelace (tADA) so the e2e +// is faucet-fundable. Set CARDANO_ASSET to a `policyId.assetNameHex` unit to run +// the same endpoint against a native token. +const CARDANO_ASSET = process.env.CARDANO_ASSET || "lovelace"; +const CARDANO_AMOUNT = process.env.CARDANO_AMOUNT || "5000000"; const TVM_PAYEE_ADDRESS = process.env.TVM_PAYEE_ADDRESS as string | undefined; const NEAR_NETWORK = (process.env.NEAR_NETWORK || "near:testnet") as `${string}:${string}`; const NEAR_PAYEE_ADDRESS = process.env.NEAR_PAYEE_ADDRESS as string | undefined; @@ -125,7 +133,14 @@ if (!facilitatorUrl) { const app = Fastify(); // Create HTTP facilitator client -const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); +// Cardano settles on ~20-second blocks, so its `settle()` cannot finish inside +// the 30s facilitator-client default. Raising the ceiling costs nothing on the +// fast chains — they still return as soon as they are done. +const FACILITATOR_TIMEOUT_MS = 180_000; +const facilitatorClient = new HTTPFacilitatorClient({ + url: facilitatorUrl, + timeoutMs: FACILITATOR_TIMEOUT_MS, +}); // Create x402 resource server const server = new x402ResourceServer(facilitatorClient); @@ -723,8 +738,9 @@ paymentMiddleware( accepts: { payTo: CARDANO_PAYEE_ADDRESS!, scheme: "exact", - price: "$0.001", + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, + extra: { confirmationPolicy: CARDANO_CONFIRMATION_POLICY }, }, extensions: { ...declareDiscoveryExtension({ diff --git a/e2e/servers/hono/index.ts b/e2e/servers/hono/index.ts index 27b797e79d..748a276da2 100644 --- a/e2e/servers/hono/index.ts +++ b/e2e/servers/hono/index.ts @@ -46,6 +46,9 @@ const AVM_NETWORK = (process.env.AVM_NETWORK || const KEETA_NETWORK = (process.env.KEETA_NETWORK || KEETA_TESTNET_CAIP2) as `${string}:${string}`; const STELLAR_NETWORK = (process.env.STELLAR_NETWORK || "stellar:testnet") as `${string}:${string}`; const CARDANO_NETWORK = (process.env.CARDANO_NETWORK || "cardano:preprod") as `${string}:${string}`; +// Canonical block inclusion is the bar for the e2e: the spec default of 1 would +// make every Cardano scenario wait an extra ~20s block for no added signal. +const CARDANO_CONFIRMATION_POLICY = { l1Confirmations: 0 }; const TVM_NETWORK = (process.env.TVM_NETWORK || "tvm:-3") as `${string}:${string}`; const NEAR_NETWORK = (process.env.NEAR_NETWORK || "near:testnet") as `${string}:${string}`; const XRPL_NETWORK = (process.env.XRPL_NETWORK || "xrpl:1") as `${string}:${string}`; @@ -60,6 +63,11 @@ const KEETA_PAYEE_ADDRESS = process.env.KEETA_PAYEE_ADDRESS as string | undefine const AVM_PAYEE_ADDRESS = process.env.AVM_PAYEE_ADDRESS as string; const STELLAR_PAYEE_ADDRESS = process.env.STELLAR_PAYEE_ADDRESS as string | undefined; const CARDANO_PAYEE_ADDRESS = process.env.CARDANO_PAYEE_ADDRESS as string | undefined; +// Asset the Cardano endpoint charges in; defaults to lovelace (tADA) so the e2e +// is faucet-fundable. Set CARDANO_ASSET to a `policyId.assetNameHex` unit to run +// the same endpoint against a native token. +const CARDANO_ASSET = process.env.CARDANO_ASSET || "lovelace"; +const CARDANO_AMOUNT = process.env.CARDANO_AMOUNT || "5000000"; const TVM_PAYEE_ADDRESS = process.env.TVM_PAYEE_ADDRESS as string | undefined; const NEAR_PAYEE_ADDRESS = process.env.NEAR_PAYEE_ADDRESS as string | undefined; const NEAR_ASSET = process.env.NEAR_ASSET as string | undefined; @@ -125,10 +133,18 @@ if (!facilitatorUrl) { const app = new Hono(); // Create facilitator clients (mock facilitator as fallback for startup validation) -const facilitatorClients = [new HTTPFacilitatorClient({ url: facilitatorUrl })]; +// Cardano settles on ~20-second blocks, so its `settle()` cannot finish inside +// the 30s facilitator-client default. Raising the ceiling costs nothing on the +// fast chains — they still return as soon as they are done. +const FACILITATOR_TIMEOUT_MS = 180_000; +const facilitatorClients = [ + new HTTPFacilitatorClient({ url: facilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), +]; const mockFacilitatorUrl = process.env.MOCK_FACILITATOR_URL; if (mockFacilitatorUrl) { - facilitatorClients.push(new HTTPFacilitatorClient({ url: mockFacilitatorUrl })); + facilitatorClients.push( + new HTTPFacilitatorClient({ url: mockFacilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), + ); } // Create x402 resource server with builder pattern (cleaner!) @@ -827,8 +843,9 @@ app.use( accepts: { payTo: CARDANO_PAYEE_ADDRESS!, scheme: "exact", - price: "$0.001", + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, + extra: { confirmationPolicy: CARDANO_CONFIRMATION_POLICY }, }, extensions: { ...declareDiscoveryExtension({ diff --git a/e2e/servers/next/app/api/exact/cardano/withx402/route.ts b/e2e/servers/next/app/api/exact/cardano/withx402/route.ts index 89cc229a44..9d5e971ace 100644 --- a/e2e/servers/next/app/api/exact/cardano/withx402/route.ts +++ b/e2e/servers/next/app/api/exact/cardano/withx402/route.ts @@ -1,7 +1,14 @@ import { NextRequest, NextResponse } from "next/server"; import { withX402 } from "@x402/next"; import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; -import { server, CARDANO_PAYEE_ADDRESS, CARDANO_NETWORK } from "@/proxy"; +import { + server, + CARDANO_PAYEE_ADDRESS, + CARDANO_NETWORK, + CARDANO_ASSET, + CARDANO_AMOUNT, + CARDANO_CONFIRMATION_POLICY, +} from "@/proxy"; /** * Handler for the protected endpoint @@ -24,8 +31,9 @@ export const GET = CARDANO_PAYEE_ADDRESS accepts: { payTo: CARDANO_PAYEE_ADDRESS, scheme: "exact", - price: "$0.001", + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, + extra: { confirmationPolicy: CARDANO_CONFIRMATION_POLICY }, }, extensions: { ...declareDiscoveryExtension({ diff --git a/e2e/servers/next/proxy.ts b/e2e/servers/next/proxy.ts index b7289679ef..470cd57def 100644 --- a/e2e/servers/next/proxy.ts +++ b/e2e/servers/next/proxy.ts @@ -48,6 +48,14 @@ export const STELLAR_NETWORK = (process.env.STELLAR_NETWORK || "stellar:testnet") as `${string}:${string}`; export const CARDANO_NETWORK = (process.env.CARDANO_NETWORK || "cardano:preprod") as `${string}:${string}`; +// Asset the Cardano endpoints charge in; defaults to lovelace (tADA) so the e2e +// is faucet-fundable. Set CARDANO_ASSET to a `policyId.assetNameHex` unit to run +// the same endpoints against a native token. +export const CARDANO_ASSET = process.env.CARDANO_ASSET || "lovelace"; +export const CARDANO_AMOUNT = process.env.CARDANO_AMOUNT || "5000000"; +// Canonical block inclusion is the bar for the e2e: the spec default of 1 would +// make every Cardano scenario wait an extra ~20s block for no added signal. +export const CARDANO_CONFIRMATION_POLICY = { l1Confirmations: 0 }; export const TVM_NETWORK = (process.env.TVM_NETWORK || "tvm:-3") as `${string}:${string}`; export const NEAR_PAYEE_ADDRESS = process.env.NEAR_PAYEE_ADDRESS as string | undefined; export const NEAR_NETWORK = (process.env.NEAR_NETWORK || "near:testnet") as `${string}:${string}`; @@ -106,10 +114,18 @@ if (!facilitatorUrl) { } // Create facilitator clients (mock facilitator as fallback for startup validation) -const facilitatorClients = [new HTTPFacilitatorClient({ url: facilitatorUrl })]; +// Cardano settles on ~20-second blocks, so its `settle()` cannot finish inside +// the 30s facilitator-client default. Raising the ceiling costs nothing on the +// fast chains — they still return as soon as they are done. +const FACILITATOR_TIMEOUT_MS = 180_000; +const facilitatorClients = [ + new HTTPFacilitatorClient({ url: facilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), +]; const mockFacilitatorUrl = process.env.MOCK_FACILITATOR_URL; if (mockFacilitatorUrl) { - facilitatorClients.push(new HTTPFacilitatorClient({ url: mockFacilitatorUrl })); + facilitatorClients.push( + new HTTPFacilitatorClient({ url: mockFacilitatorUrl, timeoutMs: FACILITATOR_TIMEOUT_MS }), + ); } // Create x402 resource server with builder pattern (cleaner!) @@ -432,8 +448,9 @@ export const proxy = paymentProxy( accepts: { payTo: CARDANO_PAYEE_ADDRESS, scheme: "exact", - price: "$0.001", + price: { amount: CARDANO_AMOUNT, asset: CARDANO_ASSET }, network: CARDANO_NETWORK, + extra: { confirmationPolicy: CARDANO_CONFIRMATION_POLICY }, }, extensions: { ...declareDiscoveryExtension({ diff --git a/e2e/src/networks/networks.ts b/e2e/src/networks/networks.ts index 9559974664..4229749ee4 100644 --- a/e2e/src/networks/networks.ts +++ b/e2e/src/networks/networks.ts @@ -6,7 +6,7 @@ */ export type NetworkMode = 'testnet' | 'mainnet'; -export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'keeta' | 'near' | 'stellar' | 'ccd' | 'tvm' | 'cardano' | 'xrpl'; +export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'keeta' | 'near' | 'stellar' | 'ccd' | 'tvm' | 'xrpl' | 'cardano'; export type NetworkConfig = { name: string; @@ -195,7 +195,7 @@ export function resolveEvmPermit2Asset(networks: NetworkSet): string { * Get network config for a protocol family in a given mode * * @param mode - 'testnet' or 'mainnet' - * @param protocolFamily - 'evm', 'svm', 'avm', 'aptos', 'hedera', 'near', 'stellar', 'ccd', 'tvm', 'cardano', or 'xrpl' + * @param protocolFamily - 'evm', 'svm', 'avm', 'aptos', 'hedera', 'near', 'stellar', 'ccd', 'tvm', 'xrpl', or 'cardano' * @returns NetworkConfig for the specified protocol */ export function getNetworkForProtocol( @@ -213,6 +213,6 @@ export function getNetworkForProtocol( */ export function getNetworkModeDescription(mode: NetworkMode): string { const set = NETWORK_SETS[mode]; - const networks = [set.evm.name, set.svm.name, set.avm.name, set.aptos.name, set.hedera.name, set.keeta.name, set.near.name, set.stellar.name, set.ccd.name, set.tvm.name, set.cardano.name, set.xrpl.name]; + const networks = [set.evm.name, set.svm.name, set.avm.name, set.aptos.name, set.hedera.name, set.keeta.name, set.near.name, set.stellar.name, set.ccd.name, set.tvm.name, set.xrpl.name, set.cardano.name]; return networks.join(' + '); } diff --git a/e2e/src/servers/generic-server.ts b/e2e/src/servers/generic-server.ts index 4d1915ed54..e5bffbd382 100644 --- a/e2e/src/servers/generic-server.ts +++ b/e2e/src/servers/generic-server.ts @@ -140,6 +140,9 @@ export class GenericServerProxy extends BaseProxy implements ServerProxy { // Cardano network config CARDANO_NETWORK: config.networks.cardano.caip2, CARDANO_PAYEE_ADDRESS: config.cardanoPayTo, + // Only forwarded when set, so the servers keep their lovelace defaults. + ...(config.cardanoAsset !== undefined ? { CARDANO_ASSET: config.cardanoAsset } : {}), + ...(config.cardanoAmount !== undefined ? { CARDANO_AMOUNT: config.cardanoAmount } : {}), BLOCKFROST_PROJECT_ID: process.env.BLOCKFROST_PROJECT_ID || '', BLOCKFROST_PREPROD_URL: config.networks.cardano.rpcUrl, // NEAR network config diff --git a/e2e/src/types.ts b/e2e/src/types.ts index a6203a1dd6..8db6a33b02 100644 --- a/e2e/src/types.ts +++ b/e2e/src/types.ts @@ -1,6 +1,6 @@ import type { NetworkSet } from './networks/networks'; -export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'keeta' | 'near' | 'stellar' | 'ccd' | 'tvm' | 'cardano' | 'xrpl'; +export type ProtocolFamily = 'evm' | 'svm' | 'avm' | 'aptos' | 'hedera' | 'keeta' | 'near' | 'stellar' | 'ccd' | 'tvm' | 'xrpl' | 'cardano'; export type Transport = 'http' | 'mcp'; export type PaymentScheme = 'exact' | 'upto' | 'batch-settlement'; export type AssetTransferMethod = 'eip3009' | 'permit2' | 'sequence' | 'ticketSequence'; @@ -128,6 +128,8 @@ export interface ServerConfig { stellarPayTo: string; tvmPayTo: string; cardanoPayTo: string; + cardanoAsset?: string; + cardanoAmount?: string; nearPayTo: string; nearAsset?: string; nearAmount?: string; diff --git a/e2e/test.ts b/e2e/test.ts index 9ee4be4853..07b7af4ad0 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -697,6 +697,8 @@ async function waitForCardanoWalletSettled(opts: { await new Promise(resolve => setTimeout(resolve, intervalMs)); } verboseLog(` ⚠️ Timed out waiting for Blockfrost to reflect ${txHash.slice(0, 12)}…; proceeding`); +} + function waitForChildProcess(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null || child.signalCode !== null) { return Promise.resolve(true); @@ -1702,6 +1704,8 @@ async function runTest() { stellarPayTo: facilitatorSupportsStellar ? (serverStellarAddress || '') : '', tvmPayTo: facilitatorSupportsTvm ? (serverTvmAddress || '') : '', cardanoPayTo: facilitatorSupportsCardano ? (serverCardanoAddress || '') : '', + cardanoAsset: process.env.SERVER_CARDANO_ASSET, + cardanoAmount: process.env.SERVER_CARDANO_AMOUNT, nearPayTo: facilitatorSupportsNear ? (serverNearAddress || '') : '', nearAsset: process.env.SERVER_NEAR_ASSET, nearAmount: process.env.SERVER_NEAR_AMOUNT, diff --git a/examples/typescript/clients/advanced/README.md b/examples/typescript/clients/advanced/README.md index e69de29bb2..ad78abb6ba 100644 --- a/examples/typescript/clients/advanced/README.md +++ b/examples/typescript/clients/advanced/README.md @@ -0,0 +1,274 @@ +# Advanced x402 Client Examples + +Advanced patterns for x402 TypeScript clients demonstrating builder pattern registration, payment lifecycle hooks, and network preferences. + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const client = new x402Client() + .register("eip155:*", new ExactEvmScheme(privateKeyToAccount(evmPrivateKey))) + .onBeforePaymentCreation(async ctx => { + console.log("Creating payment for:", ctx.selectedRequirements.network); + }) + .onAfterPaymentCreation(async ctx => { + console.log("Payment created:", ctx.paymentPayload.x402Version); + }); + +const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const response = await fetchWithPayment("http://localhost:4021/weather"); +``` + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- Valid EVM, SVM, Stellar and/or Keeta private keys for making payments +- A running x402 server (see [server examples](../../servers/)) +- Familiarity with the [basic fetch client](../fetch/) + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `CARDANO_MNEMONIC` - Cardano wallet seed phrase for signing payments (optional) +- `CARDANO_NETWORK` - Cardano network (optional, defaults to `cardano:preprod`) +- `BLOCKFROST_PREPROD_URL` / `BLOCKFROST_PROJECT_ID` - Blockfrost endpoint + project id (required when `CARDANO_MNEMONIC` is set) +- `APTOS_PRIVATE_KEY` - Aptos Ed25519 private key for Aptos payments (optional; `all-networks`) +- `CCD_PRIVATE_KEY` - Concordium Ed25519 private key for Concordium payments (optional; `all-networks`) +- `CCD_ADDRESS` - Concordium account address for Concordium payments (optional; `all-networks`) +- `EVM_PRIVATE_KEY` - Ethereum private key for EVM payments +- `SVM_PRIVATE_KEY` - Solana private key for SVM payments +- `STELLAR_PRIVATE_KEY` - Stellar secret key (starts with `S`) for signing Stellar payments +- `HEDERA_ACCOUNT_ID` - Hedera account id for Hedera payments (optional) +- `HEDERA_PRIVATE_KEY` - Hedera **ECDSA** private key (0x-prefixed or DER-encoded) for Hedera payments (optional) +- `HEDERA_NETWORK` - Hedera network (optional, defaults to `hedera:testnet`) +- `KEETA_MNEMONIC` - Keeta mnemonic for Keeta payments +- `XRPL_SEED` - XRPL family seed (starts with `s`) for XRPL payments (optional; `all-networks`) +- `XRPL_NETWORK` - XRPL network CAIP-2 (optional, defaults to `xrpl:1` XRPL Testnet) +- `XRPL_WS_URL` - Custom XRPL WebSocket endpoint (optional, defaults to the public endpoint for `XRPL_NETWORK`) + +2. Install and build all packages from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd clients/advanced +``` + +3. Run the server + +```bash +pnpm dev +``` + +### Account Setup Instructions + +#### Cardano Testnet + +The client wallet must hold the asset it pays with. Get test ADA (tADA) for `cardano:preprod` (or `cardano:preview`) from the [Cardano testnets faucet](https://docs.cardano.org/cardano-testnets/tools/faucet/), and supply a Blockfrost project id via `BLOCKFROST_PROJECT_ID` + `BLOCKFROST_PREPROD_URL`. Use `asset: "lovelace"` for quick testing (fundable directly from the faucet); preprod USDM must be sourced separately. + +#### Stellar Testnet + +Stellar accounts need to be created and funded with both XLM and USDC. Instructions: + +1. Go to [Stellar Laboratory](https://lab.stellar.org/account/create) ➡️ Generate keypair ➡️ Fund account with Friendbot, then copy the `Secret` and `Public` keys so you can use them. +2. Add USDC trustline (required to transact USDC): go to [Fund Account](https://lab.stellar.org/account/fund) ➡️ Paste your `Public Key` ➡️ Add USDC Trustline ➡️ paste your `Secret key` ➡️ Sign transaction ➡️ Add Trustline. +3. Get testnet USDC from [Circle Faucet](https://faucet.circle.com/) (select Stellar network). + +#### Keeta Testnet + +To create a Keeta Testnet wallet: + +1. Go to [Keeta Testnet Wallet](https://wallet.test.keeta.com/) and follow the steps to create your wallet. Make sure to save your mnemonic (seed phrase) to keep access to your wallet. To get your Keeta address, click on "Receive" and copy the deposit address (starting with `keeta_`). +2. Use the [Keeta Testnet Faucet](https://faucet.test.keeta.com/) to send Testnet KTA to your wallet. +3. To get Testnet USDC on Keeta, go to the "Receive" page in the wallet, click on "Any token from Keeta Testnet", select "USDC from Base (Sepolia) Testnet" and copy the deposit address (starting with `0x`). Then go the [Circle Faucet](https://faucet.circle.com/), select Base network and enter your Base deposit address. + +#### Aptos Testnet + +For testing on Aptos testnet, you can obtain test tokens from these faucets: + +- **Test APT**: https://aptos.dev/network/faucet or through an account on [geomi.dev](https://geomi.dev/manage/faucet) +- **Test USDC**: https://faucet.circle.com/ + +#### Concordium Testnet + +To get test CCD: + +1. Set up [Concordium Wallet for Web](https://wallet.testnet.concordium.com/) on **Testnet**. +2. Open the account in the wallet. +3. Go to **Activity**. +4. Click **Request CCD**. +5. Wait for the test CCD transfer to arrive. Official guide: [Request CCD](https://docs.concordium.com/en/mainnet/docs/plt/setup-guide/request-ccd.html). + +To get test PLT, there is no universal public faucet for arbitrary PLT symbols. Either: + +1. Use a token issuer's own test distribution for the symbol you want to use, or +2. Request your own PLT issuance on testnet, then mint/distribute balances from the nominated governance account. Official guide: [Request PLT](https://docs.concordium.com/en/mainnet/tutorials/plt/request-plt.html). + +#### XRPL Testnet + +To create and fund an XRPL Testnet payer account: + +1. Use the [XRPL Testnet faucet](https://xrpl.org/resources/dev-tools/xrp-faucets) to generate a funded account, and copy its family seed (starts with `s`) into `XRPL_SEED`. +2. Keep the [base reserve](https://xrpl.org/docs/concepts/accounts/reserves) funded (currently 1 XRP; the faucet funding is more than enough). The `all-networks` example pays in XRP drops, so no further setup is needed. +3. For issued-currency (IOU) payments, the payer needs a sufficient issued-currency balance, and the receiving account must hold a [trust line](https://xrpl.org/docs/concepts/tokens/fungible-tokens) to the issuer. + +## Available Examples + +Each example demonstrates a specific advanced pattern: + +| Example | Command | Description | +| --- | --- | --- | +| `all-networks` | `pnpm dev:all-networks` | All supported networks with optional chain configuration | +| `builder-pattern` | `pnpm dev:builder-pattern` | Fine-grained network registration | +| `hooks` | `pnpm dev:hooks` | Payment lifecycle hooks | +| `preferred-network` | `pnpm dev:preferred-network` | Client-side network preferences | + +## Testing the Examples + +Start a server first: + +```bash +cd ../../servers/express +pnpm dev +``` + +Then run the examples: + +```bash +cd ../../clients/advanced +pnpm dev:builder-pattern +``` + +## Example: Builder Pattern Registration + +Use the builder pattern for fine-grained control over which networks are supported and with which signers: + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactKeetaScheme } from "@x402/keeta/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { ExactStellarScheme } from "@x402/stellar/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const evmSigner = privateKeyToAccount(evmPrivateKey); +const mainnetSigner = privateKeyToAccount(mainnetPrivateKey); + +// More specific patterns take precedence over wildcards +const client = new x402Client() + .register("eip155:*", new ExactEvmScheme(evmSigner)) // All EVM networks + .register("eip155:1", new ExactEvmScheme(mainnetSigner)) // Ethereum mainnet override + .register("keeta:*", new ExactKeetaScheme(keetaSigner)) // All Keeta networks + .register("solana:*", new ExactSvmScheme(svmSigner)) // All Solana networks + .register("stellar:*", new ExactStellarScheme(stellarSigner)); // All Stellar networks + +const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const response = await fetchWithPayment("http://localhost:4021/weather"); +``` + +**Use case:** + +- Different signers for mainnet vs testnet +- Separate keys for different networks +- Explicit control over supported networks + +## Example: Payment Lifecycle Hooks + +Register custom logic at different payment stages for observability and control: + +```typescript +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { privateKeyToAccount } from "viem/accounts"; + +const signer = privateKeyToAccount(process.env.EVM_PRIVATE_KEY); + +const client = new x402Client() + .register("eip155:*", new ExactEvmScheme(signer)) + .onBeforePaymentCreation(async context => { + console.log("Creating payment for:", context.selectedRequirements); + // Abort payment by returning: { abort: true, reason: "Not allowed" } + }) + .onAfterPaymentCreation(async context => { + console.log("Payment created:", context.paymentPayload.x402Version); + // Send to analytics, database, etc. + }) + .onPaymentCreationFailure(async context => { + console.error("Payment failed:", context.error); + // Recover by returning: { recovered: true, payload: alternativePayload } + }); + +const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const response = await fetchWithPayment("http://localhost:4021/weather"); +``` + +Available hooks: + +- `onBeforePaymentCreation` — Run before payment creation (can abort) +- `onAfterPaymentCreation` — Run after successful payment creation +- `onPaymentCreationFailure` — Run when payment creation fails (can recover) + +**Use case:** + +- Log payment events for debugging and monitoring +- Custom validation before allowing payments +- Implement retry or recovery logic for failed payments +- Metrics and analytics collection + +## Example: Preferred Network Selection + +Configure client-side network preferences with automatic fallback: + +```typescript +import { x402Client, wrapFetchWithPayment, type PaymentRequirements } from "@x402/fetch"; +import { ExactEvmScheme } from "@x402/evm/exact/client"; +import { ExactKeetaScheme } from "@x402/keeta/exact/client"; +import { ExactSvmScheme } from "@x402/svm/exact/client"; +import { ExactStellarScheme } from "@x402/stellar/exact/client"; + +// Define network preference order (most preferred first) +const networkPreferences = ["eip155:", "keeta:", "solana:", "stellar:"]; + +const preferredNetworkSelector = ( + _x402Version: number, + options: PaymentRequirements[], +): PaymentRequirements => { + // Try each preference in order + for (const preference of networkPreferences) { + const match = options.find(opt => opt.network.startsWith(preference)); + if (match) return match; + } + // Fallback to first mutually-supported option + return options[0]; +}; + +const client = new x402Client(preferredNetworkSelector) + .register("eip155:*", new ExactEvmScheme(evmSigner)) + .register("keeta:*", new ExactKeetaScheme(keetaSigner)) + .register("solana:*", new ExactSvmScheme(svmSigner)) + .register("stellar:*", new ExactStellarScheme(stellarSigner)); + +const fetchWithPayment = wrapFetchWithPayment(fetch, client); +const response = await fetchWithPayment("http://localhost:4021/weather"); +``` + +**Use case:** + +- Prefer payments on specific chains +- User preference settings in wallet UIs + +## Hook Best Practices + +1. **Keep hooks fast** — Avoid blocking operations +2. **Handle errors gracefully** — Don't throw in hooks +3. **Log appropriately** — Use structured logging +4. **Avoid side effects in before hooks** — Only use for validation diff --git a/examples/typescript/clients/advanced/all_networks.ts b/examples/typescript/clients/advanced/all_networks.ts index d60fd57cd8..bd2854f48c 100644 --- a/examples/typescript/clients/advanced/all_networks.ts +++ b/examples/typescript/clients/advanced/all_networks.ts @@ -141,6 +141,17 @@ async function main(): Promise { console.log(`Initialized AVM account: ${avmSigner.address}`); } + // Register Aptos scheme if private key is provided + if (aptosPrivateKey) { + const formattedKey = AptosPrivateKey.formatPrivateKey( + aptosPrivateKey, + PrivateKeyVariants.Ed25519, + ); + const account = Account.fromPrivateKey({ privateKey: new Ed25519PrivateKey(formattedKey) }); + client.register("aptos:*", new ExactAptosScheme(account)); + console.log(`Initialized Aptos account: ${account.accountAddress.toStringLong()}`); + } + // Register Cardano scheme if a mnemonic and Blockfrost connection are provided if (cardanoMnemonic) { if (!blockfrostBaseUrl || !blockfrostProjectId) { @@ -156,15 +167,6 @@ async function main(): Promise { }); client.register("cardano:*", new ExactCardanoScheme(cardanoSigner)); console.log(`Initialized Cardano signer on ${cardanoNetwork}`); - // Register Aptos scheme if private key is provided - if (aptosPrivateKey) { - const formattedKey = AptosPrivateKey.formatPrivateKey( - aptosPrivateKey, - PrivateKeyVariants.Ed25519, - ); - const account = Account.fromPrivateKey({ privateKey: new Ed25519PrivateKey(formattedKey) }); - client.register("aptos:*", new ExactAptosScheme(account)); - console.log(`Initialized Aptos account: ${account.accountAddress.toStringLong()}`); } // Register Concordium scheme if private key and address are provided diff --git a/examples/typescript/facilitator/advanced/README.md b/examples/typescript/facilitator/advanced/README.md index e69de29bb2..802f7e1ae2 100644 --- a/examples/typescript/facilitator/advanced/README.md +++ b/examples/typescript/facilitator/advanced/README.md @@ -0,0 +1,295 @@ +# x402 Advanced Facilitator Examples + +Express.js facilitator service demonstrating advanced x402 patterns including all-networks support, bazaar discovery, Permit2 gas-sponsoring extensions (`gas_extensions`), and lifecycle hooks. + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- EVM private key with Base Sepolia ETH for transaction fees +- SVM private key with Solana Devnet SOL for transaction fees +- Stellar private key with testnet XLM for transaction fees (fund via [Stellar Laboratory](https://lab.stellar.org/account/create) ➡️ Generate keypair ➡️ Fund account with Friendbot) +- Hedera account id + private key for Hedera testnet fees (optional) +- Keeta mnemonic (seed phrase) and wallet with Testnet KTA for transaction fees (create wallet on [Keeta Testnet Wallet](https://wallet.test.keeta.com/) and fund via [Keeta Testnet Faucet](https://faucet.test.keeta.com/)) +- Cardano: a Blockfrost project id (preprod/preview) for chain queries and submission, plus an optional facilitator mnemonic — the facilitator only broadcasts the client's signed transaction, so it needs **no funds** +- No XRPL account or key: the XRPL facilitator is keyless (the payer signs and pays transaction fees); set `XRPL_NETWORK` to enable it (optional) + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `APTOS_PRIVATE_KEY` - Aptos Ed25519 private key for fee payer (optional; `all-networks`) +- `APTOS_RPC_URL` - Aptos RPC URL (optional; `all-networks`) +- `CCD_FACILITATOR_PRIVATE_KEY` - Concordium Ed25519 private key for sponsor signing (optional; `all-networks`) +- `CCD_FACILITATOR_ADDRESS` - Concordium sponsor account address (optional; `all-networks`) +- `CCD_NETWORK` - Concordium network CAIP-2 (optional; defaults to `ccd:4221332d34e1694168c2a0c0b3fd0f27`) +- `EVM_PRIVATE_KEY` - Ethereum private key +- `SVM_PRIVATE_KEY` - Solana private key +- `STELLAR_PRIVATE_KEY` - Stellar secret key (starts with `S`) +- `HEDERA_ACCOUNT_ID` - Hedera account id for fee payer (optional) +- `HEDERA_PRIVATE_KEY` - Hedera **ECDSA** private key (0x-prefixed or DER-encoded) for fee payer (optional) +- `KEETA_MNEMONIC` - Keeta mnemonic +- `CARDANO_MNEMONIC` - Cardano facilitator mnemonic (optional; only exposes an address — the facilitator needs no funds) +- `CARDANO_NETWORK` - Cardano network (optional, defaults to `cardano:preprod`) +- `BLOCKFROST_PROJECT_ID` / `BLOCKFROST_PREPROD_URL` - Blockfrost project id + endpoint (required for Cardano chain queries and submission) +- `XRPL_NETWORK` - XRPL network CAIP-2 (e.g., `xrpl:1` for XRPL Testnet); set to enable the keyless XRPL scheme (optional; `all-networks`) +- `XRPL_WS_URL` - Custom XRPL WebSocket endpoint (optional, defaults to the public endpoint for `XRPL_NETWORK`) +- `PORT` - Server port (optional, defaults to 4022) + +2. Install and build all packages from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd facilitator/advanced +``` + +3. Run an example: + +```bash +pnpm dev:all-networks # All supported networks +pnpm dev:bazaar # Bazaar discovery extension +pnpm dev:gas-extensions # exact + upto with EIP-2612 and ERC-20 approval gas sponsoring +``` + +#### Aptos Testnet + +For testing on Aptos testnet, you can obtain test tokens from these faucets: + +- **Test APT**: https://aptos.dev/network/faucet or through an account on [geomi.dev](https://geomi.dev/manage/faucet) +- **Test USDC**: https://faucet.circle.com/ + +## Available Examples + +Each example demonstrates a specific advanced pattern: + +| Example | Command | Description | +| ---------------- | ------------------------- | ------------------------------------------------------------------------- | +| `all-networks` | `pnpm dev:all-networks` | All supported networks with optional chain configuration | +| `bazaar` | `pnpm dev:bazaar` | Bazaar discovery extension for cataloging x402 resources | +| `gas_extensions` | `pnpm dev:gas-extensions` | Base Sepolia `exact` + `upto` with both Permit2 gas-sponsoring extensions | + +## API Endpoints + +### GET /supported + +Returns payment schemes and networks this facilitator supports. + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "eip155:84532" + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "keeta:1413829460" + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + "extra": { + "feePayer": "..." + } + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "stellar:testnet", + "extra": { + "areFeesSponsored": true + } + }, + { + "x402Version": 2, + "scheme": "exact", + "network": "xrpl:1", + "extra": { + "areFeesSponsored": false + } + } + ], + "extensions": [], + "signers": { + "eip155": ["0x..."], + "keeta": ["keeta_..."], + "solana": ["..."], + "stellar": ["G..."] + } +} +``` + +### POST /verify + +Verifies a payment payload against requirements before settlement. + +Request: + +```json +{ + "paymentPayload": { + "x402Version": 2, + "resource": { + "url": "http://localhost:4021/weather", + "description": "Weather data", + "mimeType": "application/json" + }, + "accepted": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2" + } + }, + "payload": { + "signature": "0x...", + "authorization": {} + } + }, + "paymentRequirements": { + "scheme": "exact", + "network": "eip155:84532", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "amount": "1000", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2" + } + } +} +``` + +Response (success): + +```json +{ + "isValid": true, + "payer": "0x..." +} +``` + +Response (failure): + +```json +{ + "isValid": false, + "invalidReason": "invalid_signature" +} +``` + +### POST /settle + +Settles a verified payment by broadcasting the transaction on-chain. + +Request body is identical to `/verify`. + +Response (success): + +```json +{ + "success": true, + "transaction": "0x...", + "network": "eip155:84532", + "payer": "0x..." +} +``` + +Response (failure): + +```json +{ + "success": false, + "errorReason": "insufficient_balance", + "transaction": "", + "network": "eip155:84532" +} +``` + +## Extending the Example + +### Adding Networks + +Register additional schemes for other networks: + +```typescript +import { registerExactEvmScheme } from "@x402/evm/exact/facilitator"; +import { registerExactSvmScheme } from "@x402/svm/exact/facilitator"; + +const facilitator = new x402Facilitator(); + +registerExactEvmScheme(facilitator, { + signer: evmSigner, + networks: "eip155:84532", +}); + +registerExactSvmScheme(facilitator, { + signer: svmSigner, + networks: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", +}); +``` + +### Lifecycle Hooks + +Add custom logic before/after verify and settle operations: + +```typescript +const facilitator = new x402Facilitator() + .onBeforeVerify(async (context) => { + // Log or validate before verification + }) + .onAfterVerify(async (context) => { + // Track verified payments + }) + .onVerifyFailure(async (context) => { + // Handle verification failures + }) + .onBeforeSettle(async (context) => { + // Validate before settlement + // Return { abort: true, reason: "..." } to cancel + }) + .onAfterSettle(async (context) => { + // Track successful settlements + }) + .onSettleFailure(async (context) => { + // Handle settlement failures + }); +``` + +## Network Identifiers + +Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format: + +- `aptos:2` — Aptos Testnet +- `aptos:1` — Aptos Mainnet +- `eip155:84532` — Base Sepolia +- `eip155:8453` — Base Mainnet +- `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` — Solana Devnet +- `solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp` — Solana Mainnet +- `stellar:testnet` — Stellar Testnet +- `stellar:pubnet` — Stellar Mainnet +- `hedera:testnet` — Hedera Testnet +- `hedera:mainnet` — Hedera Mainnet +- `keeta:1413829460` — Keeta Testnet +- `keeta:21378` — Keeta Mainnet +- `cardano:mainnet` — Cardano Mainnet +- `cardano:preprod` — Cardano Preprod Testnet +- `cardano:preview` — Cardano Preview Testnet +- `xrpl:1` — XRPL Testnet +- `xrpl:0` — XRPL Mainnet diff --git a/examples/typescript/facilitator/advanced/all_networks.ts b/examples/typescript/facilitator/advanced/all_networks.ts index e69de29bb2..d80f70afd0 100644 --- a/examples/typescript/facilitator/advanced/all_networks.ts +++ b/examples/typescript/facilitator/advanced/all_networks.ts @@ -0,0 +1,541 @@ +/** + * All Networks Facilitator Example + * + * Demonstrates how to create a facilitator that supports all available networks with + * optional chain configuration via environment variables. + * + * New chain support should be added here in alphabetic order by network prefix + * (e.g., "algorand" before "aptos" before "ccd" before "eip155" before "hedera" before "near" before "solana" before "stellar" before "tvm" before "xrpl"). + */ + +import { + Account, + Ed25519PrivateKey, + PrivateKey as AptosPrivateKey, + PrivateKeyVariants, +} from "@aptos-labs/ts-sdk"; +import * as KeetaNet from "@keetanetwork/keetanet-client"; +import { toFacilitatorAptosSigner } from "@x402/aptos"; +import { ExactAptosScheme } from "@x402/aptos/exact/facilitator"; +import { toFacilitatorAvmSigner } from "@x402/avm"; +import { ExactAvmScheme } from "@x402/avm/exact/facilitator"; +import { toFacilitatorCardanoSigner } from "@x402/cardano"; +import { ExactCardanoScheme } from "@x402/cardano/exact/facilitator"; +import { ExactConcordiumScheme } from "@x402/concordium/exact/facilitator"; +import { + CONCORDIUM_TESTNET_CAIP2, + getConcordiumGrpcUrl, + parseGrpcUrl, + toConcordiumFacilitatorSigner, +} from "@x402/concordium"; +import { x402Facilitator } from "@x402/core/facilitator"; +import { + Network, + PaymentPayload, + PaymentRequirements, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { toFacilitatorEvmSigner } from "@x402/evm"; +import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; +import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; +import { + AccountId, + Client, + PrivateKey, + createHederaClient, + createHederaPreflightTransfer, + createHederaSignAndSubmitTransaction, + createHederaVerifyPayerSignature, + toFacilitatorHederaSigner, +} from "@x402/hedera"; +import { ExactHederaScheme } from "@x402/hedera/exact/facilitator"; +import { + toFacilitatorKeetaSigner, + KEETA_TESTNET_CAIP2, + FacilitatorKeetaSigner, +} from "@x402/keeta"; +import { ExactKeetaScheme } from "@x402/keeta/exact/facilitator"; +import { + createFacilitatorNearSigner, + NEAR_TESTNET_CAIP2, + type FacilitatorRelayerConfig, +} from "@x402/near"; +import { ExactNearScheme } from "@x402/near/exact/facilitator"; +import { toFacilitatorSvmSigner } from "@x402/svm"; +import { ExactSvmScheme } from "@x402/svm/exact/facilitator"; +import { base58 } from "@scure/base"; +import { createKeyPairSignerFromBytes } from "@solana/kit"; +import { createEd25519Signer } from "@x402/stellar"; +import { ExactStellarScheme } from "@x402/stellar/exact/facilitator"; +import { + HighloadV3Config, + toFacilitatorTvmSigner, + TVM_PROVIDER_TONAPI, + TVM_PROVIDER_TONCENTER, +} from "@x402/tvm"; +import { ExactTvmScheme } from "@x402/tvm/exact/facilitator"; +import { XRPL_TESTNET } from "@x402/xrpl"; +import { ExactXrplScheme } from "@x402/xrpl/exact/facilitator"; +import dotenv from "dotenv"; +import express from "express"; +import { createWalletClient, http, publicActions } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { baseSepolia } from "viem/chains"; + +dotenv.config(); + +// Configuration +const PORT = process.env.PORT || "4022"; + +// Configuration - optional per network (alphabetic order) +const avmPrivateKey = process.env.AVM_PRIVATE_KEY as string | undefined; +const cardanoMnemonic = process.env.CARDANO_MNEMONIC as string | undefined; +const cardanoNetwork = (process.env.CARDANO_NETWORK || "cardano:preprod") as Network; +const blockfrostBaseUrl = process.env.BLOCKFROST_PREPROD_URL; +const blockfrostProjectId = process.env.BLOCKFROST_PROJECT_ID; +const aptosPrivateKey = process.env.APTOS_PRIVATE_KEY as string | undefined; +const aptosRpcUrl = process.env.APTOS_RPC_URL as string | undefined; +const ccdFacilitatorPrivateKey = process.env.CCD_FACILITATOR_PRIVATE_KEY as + | string + | undefined; +const ccdFacilitatorAddress = process.env.CCD_FACILITATOR_ADDRESS as + | string + | undefined; +const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}` | undefined; +const keetaMnemonic = process.env.KEETA_MNEMONIC as string | undefined; +const nearRelayerAccountId = process.env.NEAR_RELAYER_ACCOUNT_ID as + | string + | undefined; +const nearRelayerPrivateKey = process.env.NEAR_RELAYER_PRIVATE_KEY as + | FacilitatorRelayerConfig["secretKey"] + | undefined; +const nearNetwork = process.env.NEAR_NETWORK || NEAR_TESTNET_CAIP2; +const nearRpcUrl = process.env.NEAR_RPC_URL as string | undefined; +const svmPrivateKey = process.env.SVM_PRIVATE_KEY as string | undefined; +const stellarPrivateKey = process.env.STELLAR_PRIVATE_KEY as string | undefined; +const tvmPrivateKey = process.env.TVM_PRIVATE_KEY as string | undefined; +const hederaAccountId = process.env.HEDERA_ACCOUNT_ID; +// Hedera private key should be an ECDSA key string (0x-prefixed or DER-encoded). +const hederaPrivateKey = process.env.HEDERA_PRIVATE_KEY; +// XRPL is keyless for the facilitator: the payer signs and pays fees. +const xrplNetwork = process.env.XRPL_NETWORK || XRPL_TESTNET; +const xrplWsUrl = process.env.XRPL_WS_URL as string | undefined; + +// Validate at least one private key is provided +if ( + !avmPrivateKey && + !cardanoMnemonic && + !aptosPrivateKey && + !(ccdFacilitatorPrivateKey && ccdFacilitatorAddress) && + !evmPrivateKey && + !keetaMnemonic && + !(nearRelayerAccountId && nearRelayerPrivateKey) && + !svmPrivateKey && + !stellarPrivateKey && + !tvmPrivateKey && + !(hederaAccountId && hederaPrivateKey) +) { + console.error( + "❌ At least one of AVM_PRIVATE_KEY, APTOS_PRIVATE_KEY, CARDANO_MNEMONIC, CCD_FACILITATOR_PRIVATE_KEY + CCD_FACILITATOR_ADDRESS, EVM_PRIVATE_KEY, KEETA_MNEMONIC, NEAR_RELAYER_ACCOUNT_ID + NEAR_RELAYER_PRIVATE_KEY, SVM_PRIVATE_KEY, STELLAR_PRIVATE_KEY, TVM_PRIVATE_KEY, or HEDERA_ACCOUNT_ID + HEDERA_PRIVATE_KEY is required", + ); + process.exit(1); +} + +// Network configuration (alphabetic order) +const AVM_NETWORK = "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDe"; // Algorand Testnet +const CARDANO_NETWORK = cardanoNetwork; // Cardano Preprod Testnet (default) +const APTOS_NETWORK = (process.env.APTOS_NETWORK || "aptos:2") as Network; // Aptos Testnet +const CCD_NETWORK = "ccd:4221332d34e1694168c2a0c0b3fd0f27"; // Concordium Testnet +const EVM_NETWORK = "eip155:84532"; // Base Sepolia +const HEDERA_NETWORK = "hedera:testnet"; // Hedera Testnet +const KEETA_NETWORK = KEETA_TESTNET_CAIP2; // Keeta Testnet +const NEAR_NETWORK = nearNetwork as Network; // NEAR Testnet +const SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"; // Solana Devnet +const STELLAR_NETWORK = "stellar:testnet"; // Stellar Testnet +const TVM_NETWORK = (process.env.TVM_NETWORK || "tvm:-3") as Network; // TON Testnet +const XRPL_NETWORK = xrplNetwork as Network; // XRPL Testnet + +// Initialize the x402 Facilitator +const facilitator = new x402Facilitator() + .onBeforeVerify(async (context) => { + console.log("Before verify", context); + }) + .onAfterVerify(async (context) => { + console.log("After verify", context); + }) + .onVerifyFailure(async (context) => { + console.log("Verify failure", context); + }) + .onBeforeSettle(async (context) => { + console.log("Before settle", context); + }) + .onAfterSettle(async (context) => { + console.log("After settle", context); + }) + .onSettleFailure(async (context) => { + console.log("Settle failure", context); + }); + +// Register AVM scheme if private key is provided +if (avmPrivateKey) { + const avmSigner = toFacilitatorAvmSigner(avmPrivateKey); + console.info(`AVM Facilitator account: ${avmSigner.getAddresses()[0]}`); + facilitator.register(AVM_NETWORK, new ExactAvmScheme(avmSigner)); +} + +// Register Aptos scheme if private key is provided +if (aptosPrivateKey) { + const formattedKey = AptosPrivateKey.formatPrivateKey( + aptosPrivateKey, + PrivateKeyVariants.Ed25519, + ); + const aptosAccount = Account.fromPrivateKey({ + privateKey: new Ed25519PrivateKey(formattedKey), + }); + const aptosSigner = toFacilitatorAptosSigner( + aptosAccount, + aptosRpcUrl ? { defaultRpcUrl: aptosRpcUrl } : undefined, + ); + facilitator.register(APTOS_NETWORK, new ExactAptosScheme(aptosSigner)); + console.info( + `Aptos Facilitator account: ${aptosAccount.accountAddress.toStringLong()} on ${APTOS_NETWORK}`, + ); +} + +// Register Cardano scheme if a mnemonic and Blockfrost connection are provided +if (cardanoMnemonic) { + if (!blockfrostBaseUrl || !blockfrostProjectId) { + console.error("❌ CARDANO_MNEMONIC requires BLOCKFROST_PREPROD_URL and BLOCKFROST_PROJECT_ID"); + process.exit(1); + } + const cardanoSigner = toFacilitatorCardanoSigner({ + mnemonic: cardanoMnemonic, + network: CARDANO_NETWORK, + provider: { blockfrost: { baseUrl: blockfrostBaseUrl, projectId: blockfrostProjectId } }, + awaitConfirmation: true, + }); + console.info(`Cardano Facilitator account: ${cardanoSigner.getAddresses()[0]}`); + facilitator.register(CARDANO_NETWORK, new ExactCardanoScheme(cardanoSigner)); +} + +// Register Concordium scheme if private key + address are provided (recommended). +// This matches how every other mechanism reads a private key from an env var. +if (ccdFacilitatorPrivateKey && ccdFacilitatorAddress) { + const [host, port] = parseGrpcUrl(getConcordiumGrpcUrl(CCD_NETWORK)); + + const signer = toConcordiumFacilitatorSigner( + ccdFacilitatorAddress, + ccdFacilitatorPrivateKey, + { host, port, useTls: true }, + ); + + facilitator.register(CCD_NETWORK, new ExactConcordiumScheme({ signer })); + console.info( + `CCD Facilitator account: ${ccdFacilitatorAddress} on ${CCD_NETWORK}`, + ); +} + +// Register EVM scheme if private key is provided +if (evmPrivateKey) { + const evmAccount = privateKeyToAccount(evmPrivateKey); + console.info(`EVM Facilitator account: ${evmAccount.address}`); + + // Create a Viem client with both wallet and public capabilities + const viemClient = createWalletClient({ + account: evmAccount, + chain: baseSepolia, + transport: http(), + }).extend(publicActions); + + const evmSigner = toFacilitatorEvmSigner({ + getCode: (args: { address: `0x${string}` }) => viemClient.getCode(args), + 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}`; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) => viemClient.verifyTypedData(args as any), + writeContract: (args: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args: readonly unknown[]; + }) => + viemClient.writeContract({ + ...args, + args: args.args || [], + }), + sendTransaction: (args: { to: `0x${string}`; data: `0x${string}` }) => + viemClient.sendTransaction(args), + waitForTransactionReceipt: (args: { hash: `0x${string}` }) => + viemClient.waitForTransactionReceipt(args), + }); + + facilitator.register( + EVM_NETWORK, + new ExactEvmScheme(evmSigner, { + // Add trusted ERC-6492 factory addresses here (e.g. your chosen ERC-4337 smart wallet factory). + // A non-empty array enables smart wallet deployment; an empty array denies all factory calls. + eip6492AllowedFactories: [], + }), + ); + facilitator.register(EVM_NETWORK, new UptoEvmScheme(evmSigner)); +} + +// Register Hedera scheme if account and private key are provided +if (hederaAccountId && hederaPrivateKey) { + const hederaKey = PrivateKey.fromStringECDSA(hederaPrivateKey); + const buildHederaClient = (network: string): Client => { + const client = createHederaClient(network); + client.setOperator(AccountId.fromString(hederaAccountId), hederaKey); + return client; + }; + + const hederaSigner = toFacilitatorHederaSigner({ + getAddresses: () => [hederaAccountId], + signAndSubmitTransaction: createHederaSignAndSubmitTransaction( + buildHederaClient, + hederaKey, + ), + verifyPayerSignature: createHederaVerifyPayerSignature(), + preflightTransfer: createHederaPreflightTransfer(), + }); + facilitator.register(HEDERA_NETWORK, new ExactHederaScheme(hederaSigner)); + console.info(`Hedera Facilitator account: ${hederaAccountId}`); +} + +// Register Keeta scheme if mnemonic is provided +let keetaSigner: FacilitatorKeetaSigner | undefined; +if (keetaMnemonic) { + const keetaAccount = KeetaNet.lib.Account.fromSeed( + await KeetaNet.lib.Account.seedFromPassphrase(keetaMnemonic), + 0, + ); + console.info( + `Keeta Facilitator account: ${keetaAccount.publicKeyString.toString()}`, + ); + + keetaSigner = toFacilitatorKeetaSigner([keetaAccount]); + facilitator.register( + KEETA_NETWORK, + new ExactKeetaScheme(keetaSigner, console), + ); +} + +// Register NEAR scheme if relayer account and private key are provided +if (nearRelayerAccountId && nearRelayerPrivateKey) { + const nearSigner = createFacilitatorNearSigner({ + relayers: [ + { accountId: nearRelayerAccountId, secretKey: nearRelayerPrivateKey }, + ], + rpcUrls: nearRpcUrl ? { [NEAR_NETWORK]: nearRpcUrl } : undefined, + }); + facilitator.register(NEAR_NETWORK, new ExactNearScheme(nearSigner)); + console.info( + `NEAR Facilitator relayer account: ${nearRelayerAccountId} on ${NEAR_NETWORK}`, + ); +} + +// Register SVM scheme if private key is provided +if (svmPrivateKey) { + const svmAccount = await createKeyPairSignerFromBytes( + base58.decode(svmPrivateKey), + ); + console.info(`SVM Facilitator account: ${svmAccount.address}`); + + const svmSigner = toFacilitatorSvmSigner(svmAccount); + + facilitator.register(SVM_NETWORK, new ExactSvmScheme(svmSigner)); +} + +// Register Stellar scheme if private key is provided +if (stellarPrivateKey) { + const stellarSigner = createEd25519Signer(stellarPrivateKey); + console.info(`Stellar Facilitator account: ${stellarSigner.address}`); + + facilitator.register( + STELLAR_NETWORK, + new ExactStellarScheme([stellarSigner]), + ); +} + +// Register TVM scheme if private key is provided +if (tvmPrivateKey) { + const tvmProvider = ( + process.env.TVM_PROVIDER || TVM_PROVIDER_TONCENTER + ).toLowerCase(); + const tvmConfig = HighloadV3Config.fromPrivateKey(tvmPrivateKey, { + provider: tvmProvider, + apiKey: + tvmProvider === TVM_PROVIDER_TONAPI + ? process.env.TONAPI_API_KEY + : process.env.TONCENTER_API_KEY, + providerBaseUrl: + tvmProvider === TVM_PROVIDER_TONAPI + ? process.env.TONAPI_BASE_URL + : process.env.TONCENTER_BASE_URL, + }); + const tvmSigner = toFacilitatorTvmSigner({ [TVM_NETWORK]: tvmConfig }); + console.info( + `TVM Facilitator account: ${tvmSigner.getAddressesForNetwork(TVM_NETWORK)[0]}`, + ); + + facilitator.register(TVM_NETWORK, new ExactTvmScheme(tvmSigner)); +} + +// Register XRPL scheme if XRPL_NETWORK is set. XRPL is keyless: the payer +// signs and pays transaction fees; the facilitator only reads ledger state +// and submits the payer-signed transaction. +if (process.env.XRPL_NETWORK) { + facilitator.register( + XRPL_NETWORK, + new ExactXrplScheme( + xrplWsUrl + ? { wsUrlByNetwork: { [XRPL_NETWORK as `xrpl:${number}`]: xrplWsUrl } } + : {}, + ), + ); + console.info( + `XRPL facilitator enabled on ${XRPL_NETWORK} (payer-signed transactions; no facilitator signer)`, + ); +} + +// Initialize Express app +const app = express(); +app.use(express.json()); + +/** + * POST /verify + * Verify a payment against requirements + */ +app.post("/verify", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body as { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; + }; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const response: VerifyResponse = await facilitator.verify( + paymentPayload, + paymentRequirements, + ); + + res.json(response); + } catch (error) { + console.error("Verify error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * POST /settle + * Settle a payment on-chain + */ +app.post("/settle", async (req, res) => { + try { + const { paymentPayload, paymentRequirements } = req.body; + + if (!paymentPayload || !paymentRequirements) { + return res.status(400).json({ + error: "Missing paymentPayload or paymentRequirements", + }); + } + + const response: SettleResponse = await facilitator.settle( + paymentPayload as PaymentPayload, + paymentRequirements as PaymentRequirements, + ); + + res.json(response); + } catch (error) { + console.error("Settle error:", error); + + // Check if this was an abort from hook + if ( + error instanceof Error && + error.message.includes("Settlement aborted:") + ) { + return res.json({ + success: false, + errorReason: error.message.replace("Settlement aborted: ", ""), + network: req.body?.paymentPayload?.network || "unknown", + } as SettleResponse); + } + + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /supported + * Get supported payment kinds and extensions + */ +app.get("/supported", async (req, res) => { + try { + const response = facilitator.getSupported(); + res.json(response); + } catch (error) { + console.error("Supported error:", error); + res.status(500).json({ + error: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +/** + * GET /health + * Health check endpoint + */ +app.get("/health", (req, res) => { + res.json({ status: "ok" }); +}); + +// Start the server +let server = app.listen(parseInt(PORT), () => { + console.log( + `🚀 All Networks Facilitator listening on http://localhost:${PORT}`, + ); + console.log( + ` Supported networks: ${facilitator + .getSupported() + .kinds.map((k) => k.network) + .join(", ")}`, + ); + console.log(); +}); + +if (keetaSigner) { + const shutdown = async () => { + server.close(async () => { + await keetaSigner.destroy(); + process.exit(0); + }); + }; + process.on("SIGTERM", shutdown); + process.on("SIGINT", shutdown); +} diff --git a/examples/typescript/pnpm-lock.yaml b/examples/typescript/pnpm-lock.yaml index bfd7eff5d7..a9e7ef8528 100644 --- a/examples/typescript/pnpm-lock.yaml +++ b/examples/typescript/pnpm-lock.yaml @@ -831,10 +831,16 @@ importers: dependencies: '@evolution-sdk/evolution': specifier: ^0.5.9 - version: 0.5.12(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + version: 0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 '@x402/core': specifier: workspace:~ version: link:../../core + lz-string: + specifier: ^1.5.0 + version: 1.5.0 devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -4267,8 +4273,8 @@ packages: '@evanhahn/lottie-web-light@5.8.1': resolution: {integrity: sha512-U0G1tt3/UEYnyCNNslWPi1dB7X1xQ9aoSip+B3GTKO/Bns8yz/p39vBkRSN9d25nkbHuCsbjky2coQftj5YVKw==} - '@evolution-sdk/evolution@0.5.12': - resolution: {integrity: sha512-jEtUPlYKP94nHe+g7leZSH/jW+pSYa4US1V+Vuf+5b3s+ApfOGeYqryNGSqQN5swgamiCMmsYT+6ZjI1zTFj7Q==} + '@evolution-sdk/evolution@0.5.11': + resolution: {integrity: sha512-Y1o0x9P6v+a9YiGvBg9Rv3lJqkBqeupGI4N2J/pbLLHdnrF1sXFyLe94yvc+sCQGbHwfIPDN0XZc+SDf3W7yQw==} '@farcaster/miniapp-core@0.4.1': resolution: {integrity: sha512-20FxHTRToYUKx7CQ8PvIy9OoQ6XjdmF1pRMS7dsj37qdqjVDeEkYoK8yXwnoReZoJRcYwIg8P3i6V8bTWNR5mg==} @@ -4963,86 +4969,92 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} - '@parcel/watcher-android-arm64@2.6.0': - resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-darwin-arm64@2.6.0': - resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-x64@2.6.0': - resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-freebsd-x64@2.6.0': - resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-linux-arm-glibc@2.6.0': - resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm-musl@2.6.0': - resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - '@parcel/watcher-linux-arm64-glibc@2.6.0': - resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm64-musl@2.6.0': - resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] libc: [musl] - '@parcel/watcher-linux-x64-glibc@2.6.0': - resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [glibc] - '@parcel/watcher-linux-x64-musl@2.6.0': - resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] libc: [musl] - '@parcel/watcher-win32-arm64@2.6.0': - resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-x64@2.6.0': - resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher@2.6.0': - resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} '@paulmillr/qr@0.2.1': @@ -8217,8 +8229,8 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - effect@3.22.1: - resolution: {integrity: sha512-TNoXushmPOBAjJlthF5d2QwnX2xBPEtcNJr5XKNKbRLbDvBcOYkXlYDfvGfSA0zriwLFuCll5MDtNMAdZL17PQ==} + effect@3.22.0: + resolution: {integrity: sha512-jhYFe0zTlIRqYFrKTS+6luhmS/Tm0f+JLo0K9KUxvtFab1SUGEszQi2ehOP6QzAZvy831lDmTwwzvVDZSPNz3g==} electron-to-chromium@1.5.207: resolution: {integrity: sha512-mryFrrL/GXDTmAtIVMVf+eIXM09BBPlO5IQ7lUyKmK8d+A4VpRGG+M3ofoVef6qyF8s60rJei8ymlJxjUA8Faw==} @@ -9563,6 +9575,10 @@ packages: lute-connect@1.7.0: resolution: {integrity: sha512-/eXb2/c/xltKyVEVWchd1QZB6F0fvgXwVIqXDQWeJ9unPo0kMMbtuLkeb1v4Kr1lffxX8uGnb+8kAMYjczUASg==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -10128,10 +10144,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - picomatch@4.0.5: - resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} - engines: {node: '>=12'} - pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -11266,8 +11278,8 @@ packages: undici-types@7.22.0: resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==} - undici@7.29.0: - resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} engines: {node: '>=20.18.1'} unpipe@1.0.0: @@ -12250,76 +12262,76 @@ snapshots: dependencies: '@noble/ciphers': 1.3.0 - '@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1)': + '@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0)': dependencies: - '@effect/platform': 0.96.3(effect@3.22.1) - '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/workflow': 0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1) - effect: 3.22.1 + '@effect/platform': 0.96.3(effect@3.22.0) + '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/workflow': 0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0) + effect: 3.22.0 kubernetes-types: 1.30.0 - '@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1)': + '@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0)': dependencies: - '@effect/platform': 0.96.3(effect@3.22.1) - effect: 3.22.1 + '@effect/platform': 0.96.3(effect@3.22.0) + effect: 3.22.0 uuid: 11.1.1 - '@effect/platform-node-shared@0.59.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(effect@3.22.1)(utf-8-validate@5.0.10)': + '@effect/platform-node-shared@0.59.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(effect@3.22.0)(utf-8-validate@5.0.10)': dependencies: - '@effect/cluster': 0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1) - '@effect/platform': 0.96.3(effect@3.22.1) - '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@parcel/watcher': 2.6.0 - effect: 3.22.1 + '@effect/cluster': 0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0) + '@effect/platform': 0.96.3(effect@3.22.0) + '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@parcel/watcher': 2.5.6 + effect: 3.22.0 multipasta: 0.2.8 ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@0.106.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(effect@3.22.1)(utf-8-validate@5.0.10)': + '@effect/platform-node@0.106.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(effect@3.22.0)(utf-8-validate@5.0.10)': dependencies: - '@effect/cluster': 0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1) - '@effect/platform': 0.96.3(effect@3.22.1) - '@effect/platform-node-shared': 0.59.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(effect@3.22.1)(utf-8-validate@5.0.10) - '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - effect: 3.22.1 + '@effect/cluster': 0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0) + '@effect/platform': 0.96.3(effect@3.22.0) + '@effect/platform-node-shared': 0.59.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(effect@3.22.0)(utf-8-validate@5.0.10) + '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + effect: 3.22.0 mime: 3.0.0 - undici: 7.29.0 + undici: 7.28.0 ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform@0.96.3(effect@3.22.1)': + '@effect/platform@0.96.3(effect@3.22.0)': dependencies: - effect: 3.22.1 + effect: 3.22.0 find-my-way-ts: 0.1.6 msgpackr: 1.12.1 multipasta: 0.2.8 - '@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1)': + '@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0)': dependencies: - '@effect/platform': 0.96.3(effect@3.22.1) - effect: 3.22.1 + '@effect/platform': 0.96.3(effect@3.22.0) + effect: 3.22.0 msgpackr: 1.12.1 - '@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1)': + '@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0)': dependencies: - '@effect/experimental': 0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/platform': 0.96.3(effect@3.22.1) - effect: 3.22.1 + '@effect/experimental': 0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/platform': 0.96.3(effect@3.22.0) + effect: 3.22.0 uuid: 11.1.1 - '@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1)': + '@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0)': dependencies: - '@effect/experimental': 0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - '@effect/platform': 0.96.3(effect@3.22.1) - '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1) - effect: 3.22.1 + '@effect/experimental': 0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + '@effect/platform': 0.96.3(effect@3.22.0) + '@effect/rpc': 0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0) + effect: 3.22.0 '@emnapi/core@1.4.5': dependencies: @@ -12567,10 +12579,10 @@ snapshots: '@evanhahn/lottie-web-light@5.8.1': {} - '@evolution-sdk/evolution@0.5.12(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': + '@evolution-sdk/evolution@0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(utf-8-validate@5.0.10)': dependencies: - '@effect/platform': 0.96.3(effect@3.22.1) - '@effect/platform-node': 0.106.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(@effect/platform@0.96.3(effect@3.22.1))(effect@3.22.1))(bufferutil@4.0.9)(effect@3.22.1)(utf-8-validate@5.0.10) + '@effect/platform': 0.96.3(effect@3.22.0) + '@effect/platform-node': 0.106.0(@effect/cluster@0.58.2(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(@effect/rpc@0.75.1(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.96.3(effect@3.22.0))(effect@3.22.0))(bufferutil@4.0.9)(effect@3.22.0)(utf-8-validate@5.0.10) '@noble/curves': 2.2.0 '@noble/hashes': 2.2.0 '@scure/base': 2.2.0 @@ -12578,7 +12590,7 @@ snapshots: '@scure/bip39': 2.2.0 '@types/bip39': 3.0.4 bip39: 3.1.0 - effect: 3.22.1 + effect: 3.22.0 transitivePeerDependencies: - '@effect/cluster' - '@effect/rpc' @@ -13450,61 +13462,65 @@ snapshots: '@opentelemetry/api@1.9.1': optional: true - '@parcel/watcher-android-arm64@2.6.0': + '@parcel/watcher-android-arm64@2.5.6': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.6': optional: true - '@parcel/watcher-darwin-arm64@2.6.0': + '@parcel/watcher-darwin-x64@2.5.6': optional: true - '@parcel/watcher-darwin-x64@2.6.0': + '@parcel/watcher-freebsd-x64@2.5.6': optional: true - '@parcel/watcher-freebsd-x64@2.6.0': + '@parcel/watcher-linux-arm-glibc@2.5.6': optional: true - '@parcel/watcher-linux-arm-glibc@2.6.0': + '@parcel/watcher-linux-arm-musl@2.5.6': optional: true - '@parcel/watcher-linux-arm-musl@2.6.0': + '@parcel/watcher-linux-arm64-glibc@2.5.6': optional: true - '@parcel/watcher-linux-arm64-glibc@2.6.0': + '@parcel/watcher-linux-arm64-musl@2.5.6': optional: true - '@parcel/watcher-linux-arm64-musl@2.6.0': + '@parcel/watcher-linux-x64-glibc@2.5.6': optional: true - '@parcel/watcher-linux-x64-glibc@2.6.0': + '@parcel/watcher-linux-x64-musl@2.5.6': optional: true - '@parcel/watcher-linux-x64-musl@2.6.0': + '@parcel/watcher-win32-arm64@2.5.6': optional: true - '@parcel/watcher-win32-arm64@2.6.0': + '@parcel/watcher-win32-ia32@2.5.6': optional: true - '@parcel/watcher-win32-x64@2.6.0': + '@parcel/watcher-win32-x64@2.5.6': optional: true - '@parcel/watcher@2.6.0': + '@parcel/watcher@2.5.6': dependencies: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.5 + picomatch: 4.0.3 optionalDependencies: - '@parcel/watcher-android-arm64': 2.6.0 - '@parcel/watcher-darwin-arm64': 2.6.0 - '@parcel/watcher-darwin-x64': 2.6.0 - '@parcel/watcher-freebsd-x64': 2.6.0 - '@parcel/watcher-linux-arm-glibc': 2.6.0 - '@parcel/watcher-linux-arm-musl': 2.6.0 - '@parcel/watcher-linux-arm64-glibc': 2.6.0 - '@parcel/watcher-linux-arm64-musl': 2.6.0 - '@parcel/watcher-linux-x64-glibc': 2.6.0 - '@parcel/watcher-linux-x64-musl': 2.6.0 - '@parcel/watcher-win32-arm64': 2.6.0 - '@parcel/watcher-win32-x64': 2.6.0 + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 '@paulmillr/qr@0.2.1': {} @@ -20504,7 +20520,7 @@ snapshots: ee-first@1.1.1: {} - effect@3.22.1: + effect@3.22.0: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 @@ -22364,6 +22380,8 @@ snapshots: lute-connect@1.7.0: {} + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -23153,8 +23171,6 @@ snapshots: picomatch@4.0.3: {} - picomatch@4.0.5: {} - pify@3.0.0: {} pify@5.0.0: {} @@ -23810,7 +23826,7 @@ snapshots: buffer: 6.0.3 eventemitter3: 5.0.1 uuid: 8.3.2 - ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.19.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) optionalDependencies: bufferutil: 4.0.9 utf-8-validate: 5.0.10 @@ -24626,7 +24642,7 @@ snapshots: undici-types@7.22.0: {} - undici@7.29.0: {} + undici@7.28.0: {} unpipe@1.0.0: {} diff --git a/examples/typescript/servers/advanced/README.md b/examples/typescript/servers/advanced/README.md index e69de29bb2..6545935273 100644 --- a/examples/typescript/servers/advanced/README.md +++ b/examples/typescript/servers/advanced/README.md @@ -0,0 +1,420 @@ +# @x402/express Advanced Examples + +Express.js server demonstrating advanced x402 patterns including dynamic pricing, payment routing, lifecycle hooks and API discoverability. + +```typescript +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { ExactKeetaScheme } from "@x402/keeta/exact/server"; +import { ExactStellarScheme } from "@x402/stellar/exact/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; + +const resourceServer = new x402ResourceServer(new HTTPFacilitatorClient({ url: facilitatorUrl })) + .register("eip155:84532", new ExactEvmScheme()) + .register("keeta:*", new ExactKeetaScheme()) + .register("stellar:*", new ExactStellarScheme()) + .onBeforeVerify(async ctx => console.log("Verifying payment...")) + .onAfterSettle(async ctx => console.log("Settled:", ctx.result.transaction)); + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: ctx => (ctx.adapter.getQueryParam?.("tier") === "premium" ? "$0.01" : "$0.001"), + network: "eip155:84532", + payTo: evmAddress, + }, + }, + "GET /weather-stellar": { + accepts: { + scheme: "exact", + price: ctx => (ctx.adapter.getQueryParam?.("tier") === "premium" ? "$0.01" : "$0.001"), + network: "stellar:*", + payTo: stellarAddress, + }, + }, + "GET /weather-keeta": { + accepts: { + scheme: "exact", + price: ctx => (ctx.adapter.getQueryParam?.("tier") === "premium" ? "$0.01" : "$0.001"), + network: "keeta:*", + payTo: keetaAddress, + }, + }, + }, + resourceServer, + ), +); +``` + +## Prerequisites + +- Node.js v20+ (install via [nvm](https://github.com/nvm-sh/nvm)) +- pnpm v10 (install via [pnpm.io/installation](https://pnpm.io/installation)) +- Valid EVM for receiving payments +- URL of a facilitator supporting the desired payment network, see [facilitator list](https://www.x402.org/ecosystem?category=facilitators) + +## Setup + +1. Copy `.env-local` to `.env`: + +```bash +cp .env-local .env +``` + +and fill required environment variables: + +- `FACILITATOR_URL` - Facilitator endpoint URL +- `AVM_ADDRESS` - Algorand address to receive payments (optional for `all-networks`) +- `AVM_NETWORK` - Algorand network CAIP-2 (optional; defaults to canonical Algorand Testnet) +- `CARDANO_ADDRESS` - Cardano `addr_test1...` address to receive payments (optional for `all-networks`) +- `APTOS_ADDRESS` - Aptos account address to receive payments (optional for `all-networks`) +- `CCD_ADDRESS` - Concordium account address to receive payments (optional for `all-networks`) +- `EVM_ADDRESS` - Ethereum address to receive payments +- `SVM_ADDRESS` - Solana address to receive payments (optional for `all-networks`) +- `STELLAR_ADDRESS` - Stellar public address (starts with `G`) to receive payments +- `HEDERA_ACCOUNT_ID` - Hedera account id to receive payments (optional for `all-networks`; format: `0.0.XXXXX`) +- `KEETA_ADDRESS` - Keeta address (starts with `keeta_`) to receive payments +- `XRPL_ADDRESS` - XRPL classic address (starts with `r`) to receive payments (optional for `all-networks`) +- `XRPL_NETWORK` - XRPL network CAIP-2 (optional, defaults to `xrpl:1` XRPL Testnet) +- `XRPL_AMOUNT` - XRPL price in drops (optional, defaults to `1000` = 0.001 XRP) + +> **Hedera Testnet:** Get testnet HBAR from the [Hedera Faucet](https://portal.hedera.com/faucet). +> +> **Cardano Testnet:** Get test ADA (tADA) for preprod/preview from the [Cardano testnets faucet](https://docs.cardano.org/cardano-testnets/tools/faucet/). + +2. Install and build all packages from the typescript examples root: + +```bash +cd ../../ +pnpm install && pnpm build +cd servers/advanced +``` + +3. Run the server + +```bash +pnpm dev +``` + +### Network configuration + +The `network` in route `accepts` and `.register(...)` must **exactly match** a scheme/network pair from your facilitator's `/supported` response. The server checks this on startup; a mismatch fails initialization. + +Check what your facilitator supports before configuring networks: + +```bash +curl -s "$FACILITATOR_URL/supported" | jq '.kinds[] | {scheme, network}' +``` + +### Account Setup Instructions + +#### Stellar Testnet + +Stellar accounts need to be created and funded with both XLM and USDC. Instructions: + +1. Go to [Stellar Laboratory](https://lab.stellar.org/account/create) ➡️ Generate keypair ➡️ Fund account with Friendbot, then copy the `Secret` and `Public` keys so you can use them. +2. Add USDC trustline (required to transact USDC): go to [Fund Account](https://lab.stellar.org/account/fund) ➡️ Paste your `Public Key` ➡️ Add USDC Trustline ➡️ paste your `Secret key` ➡️ Sign transaction ➡️ Add Trustline. +3. Get testnet USDC from [Circle Faucet](https://faucet.circle.com/) (select Stellar network). + +#### Keeta Testnet + +To create a Keeta Testnet wallet: + +1. Go to [Keeta Testnet Wallet](https://wallet.test.keeta.com/) and follow the steps to create your wallet. Make sure to save your mnemonic (seed phrase) to keep access to your wallet. To get your Keeta address, click on "Receive" and copy the deposit address (starting with `keeta_`). +2. Use the [Keeta Testnet Faucet](https://faucet.test.keeta.com/) to send Testnet KTA to your wallet. +3. To get Testnet USDC on Keeta, go to the "Receive" page in the wallet, click on "Any token from Keeta Testnet", select "USDC from Base (Sepolia) Testnet" and copy the deposit address (starting with `0x`). Then go the [Circle Faucet](https://faucet.circle.com/), select Base network and enter your Base deposit address. + +#### Aptos Testnet + +For testing on Aptos testnet, you can obtain test tokens from these faucets: + +- **Test APT**: https://aptos.dev/network/faucet or through an account on [geomi.dev](https://geomi.dev/manage/faucet) +- **Test USDC**: https://faucet.circle.com/ + +#### XRPL Testnet + +The receiving account must exist on the ledger, i.e. hold the [base reserve](https://xrpl.org/docs/concepts/accounts/reserves) (currently 1 XRP): + +1. Use the [XRPL Testnet faucet](https://xrpl.org/resources/dev-tools/xrp-faucets) to generate a funded account, and copy its classic address (starts with `r`) into `XRPL_ADDRESS`. +2. The `all-networks` example prices in XRP drops, so no further setup is needed. To receive issued-currency (IOU) payments instead, the receiving account must hold a [trust line](https://xrpl.org/docs/concepts/tokens/fungible-tokens) to the issuer. + +## Available Examples + +Each example demonstrates a specific advanced pattern: + +| Example | Command | Description | +| --- | --- | --- | +| `all-networks` | `pnpm dev:all-networks` | All supported networks with optional chain configuration | +| `bazaar` | `pnpm dev:bazaar` | API discoverability via Bazaar | +| `hooks` | `pnpm dev:hooks` | Payment lifecycle hooks | +| `dynamic-price` | `pnpm dev:dynamic-price` | Context-based pricing | +| `dynamic-pay-to` | `pnpm dev:dynamic-pay-to` | Route payments to different recipients | +| `custom-money-definition` | `pnpm dev:custom-money-definition` | Accept alternative tokens | + +## Testing the Server + +You can test the server using one of the example clients: + +### Using the Fetch Client + +```bash +cd ../../clients/fetch +# Ensure .env is setup +pnpm dev +``` + +### Using the Axios Client + +```bash +cd ../../clients/axios +# Ensure .env is setup +pnpm dev +``` + +## Example: Bazaar Discovery + +Adding the discovery extension to make your API discoverable: + +```typescript +import { declareDiscoveryExtension } from "@x402/extensions/bazaar"; + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: evmAddress, + }, + description: "Weather data", + mimeType: "application/json", + extensions: { + ...declareDiscoveryExtension({ + input: { city: "San Francisco" }, + inputSchema: { + properties: { city: { type: "string" } }, + required: ["city"], + }, + output: { + example: { city: "San Francisco", weather: "foggy", temperature: 60 }, + }, + }), + }, + }, + }, + resourceServer, + ), +); +``` + +**Use case:** Clients and AI agents can easily discover your service + +## Example: Dynamic Pricing + +Calculate prices at runtime based on request context: + +```typescript +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: context => { + const tier = context.adapter.getQueryParam?.("tier") ?? "standard"; + return tier === "premium" ? "$0.005" : "$0.001"; + }, + network: "eip155:84532", + payTo: evmAddress, + }, + }, + }, + resourceServer, + ), +); +``` + +**Use case:** Implementing tiered pricing, user-based pricing, content-based pricing or any scenario where the price varies based on the request. + +## Example: Dynamic PayTo + +Route payments to different recipients based on request context: + +```typescript +const addressLookup: Record = { + US: "0x...", + UK: "0x...", +}; + +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts: { + scheme: "exact", + price: "$0.001", + network: "eip155:84532", + payTo: context => { + const country = context.adapter.getQueryParam?.("country") ?? "US"; + return addressLookup[country]; + }, + }, + }, + }, + resourceServer, + ), +); +``` + +**Use case:** Marketplace applications where payments should go to different sellers, content creators, or service providers based on the resource being accessed. + +## Example: Lifecycle Hooks + +Run custom logic before/after verification and settlement: + +```typescript +const resourceServer = new x402ResourceServer(facilitatorClient) + .register("eip155:84532", new ExactEvmScheme()) + .onBeforeVerify(async context => { + console.log("Before verify hook", context); + // Abort verification by returning { abort: true, reason: string } + }) + .onAfterSettle(async context => { + await logPaymentToDatabase(context); + }) + .onSettleFailure(async context => { + // Return a result with recovered=true to recover from the failure + // return { recovered: true, result: { success: true, transaction: "0x123..." } }; + }); +``` + +Available hooks: + +- `onBeforeVerify` — Run before verification (can abort) +- `onAfterVerify` — Run after successful verification +- `onVerifyFailure` — Run when verification fails (can recover) +- `onBeforeSettle` — Run before settlement (can abort) +- `onAfterSettle` — Run after successful settlement +- `onSettleFailure` — Run when settlement fails (can recover) + +**Use case:** + +- Log payment events to a database or monitoring system +- Perform custom validation before processing payments +- Implement retry or recovery logic for failed payments +- Trigger side effects (notifications, database updates) after successful payments + +## Example: Custom Tokens + +Accept payments in custom tokens. Register a money parser on the scheme to support alternative tokens for specific networks. + +```typescript +import { ExactEvmScheme } from "@x402/evm/exact/server"; + +const resourceServer = new x402ResourceServer(facilitatorClient).register( + "eip155:84532", + new ExactEvmScheme().registerMoneyParser(async (amount, network) => { + // Use Wrapped XDAI on Gnosis Chain + if (network === "eip155:100") { + return { + amount: BigInt(Math.round(amount * 1e18)).toString(), + asset: "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", + extra: { token: "Wrapped XDAI" }, + }; + } + return null; // Fall through to default parser + }), +); + +// Use in payment requirements +"GET /weather": { + accepts: { + scheme: "exact", + price: "$0.001", + network: "eip155:100", + payTo: evmAddress, + }, +}, +``` + +**Use case:** When you want to accept payments in tokens other than USDC, or use different tokens based on conditions (e.g., DAI for large amounts, custom tokens for specific networks). + +## Response Format + +### Payment Required (402) + +``` +HTTP/1.1 402 Payment Required +Content-Type: application/json; charset=utf-8 +PAYMENT-REQUIRED: + +{} +``` + +The `PAYMENT-REQUIRED` header contains base64-encoded JSON with the payment requirements. Note: `amount` is in atomic units (e.g., 1000 = 0.001 USDC, since USDC has 6 decimals). + +```json +{ + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": "http://localhost:4021/weather", + "description": "Weather data", + "mimeType": "application/json" + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2", + "resourceUrl": "http://localhost:4021/weather" + } + } + ] +} +``` + +### Successful Response + +``` +HTTP/1.1 200 OK +Content-Type: application/json; charset=utf-8 +PAYMENT-RESPONSE: + +{"report":{"weather":"sunny","temperature":70}} +``` + +The `PAYMENT-RESPONSE` header contains base64-encoded JSON with the settlement details: + +```json +{ + "success": true, + "transaction": "0x...", + "network": "eip155:84532", + "payer": "0x...", + "requirements": { + "scheme": "exact", + "network": "eip155:84532", + "amount": "1000", + "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + "payTo": "0x...", + "maxTimeoutSeconds": 300, + "extra": { + "name": "USDC", + "version": "2", + "resourceUrl": "http://localhost:4021/weather" + } + } +} +``` diff --git a/examples/typescript/servers/advanced/all_networks.ts b/examples/typescript/servers/advanced/all_networks.ts index e69de29bb2..08f7687c52 100644 --- a/examples/typescript/servers/advanced/all_networks.ts +++ b/examples/typescript/servers/advanced/all_networks.ts @@ -0,0 +1,323 @@ +/** + * All Networks Server Example + * + * Demonstrates how to create a server that supports all available networks with + * optional chain configuration via environment variables. + * + * New chain support should be added here in alphabetic order by network prefix + * (e.g., "algorand" before "aptos" before "ccd" before "eip155" before "hedera" before "near" before "solana" before "stellar" before "tvm" before "xrpl"). + */ + +import { config } from "dotenv"; +import express from "express"; +import { paymentMiddleware, x402ResourceServer } from "@x402/express"; +import { APTOS_TESTNET_CAIP2 } from "@x402/aptos"; +import { ExactAptosScheme } from "@x402/aptos/exact/server"; +import { ExactAvmScheme } from "@x402/avm/exact/server"; +import { ALGORAND_TESTNET_CAIP2 } from "@x402/avm"; +import { ExactCardanoScheme } from "@x402/cardano/exact/server"; +import { ExactConcordiumScheme } from "@x402/concordium/exact/server"; +import { ExactEvmScheme } from "@x402/evm/exact/server"; +import { ExactHederaScheme } from "@x402/hedera/exact/server"; +import { ExactSvmScheme } from "@x402/svm/exact/server"; +import { KEETA_TESTNET_CAIP2 } from "@x402/keeta"; +import { ExactKeetaScheme } from "@x402/keeta/exact/server"; +import { NEAR_TESTNET_CAIP2 } from "@x402/near"; +import { ExactNearScheme } from "@x402/near/exact/server"; +import { ExactStellarScheme } from "@x402/stellar/exact/server"; +import { ExactTvmScheme } from "@x402/tvm/exact/server"; +import { XRPL_TESTNET } from "@x402/xrpl"; +import { ExactXrplScheme } from "@x402/xrpl/exact/server"; +import { HTTPFacilitatorClient } from "@x402/core/server"; +import type { Network, Price } from "@x402/core/types"; + +config(); + +// Configuration - optional per network +const avmAddress = process.env.AVM_ADDRESS as string | undefined; +const cardanoAddress = process.env.CARDANO_ADDRESS as string | undefined; +const aptosAddress = process.env.APTOS_ADDRESS as string | undefined; +const ccdAddress = process.env.CCD_ADDRESS as string | undefined; +const evmAddress = process.env.EVM_ADDRESS as `0x${string}` | undefined; +const hederaAddress = process.env.HEDERA_ACCOUNT_ID as string | undefined; +const keetaAddress = process.env.KEETA_ADDRESS as string | undefined; +const nearAddress = process.env.NEAR_ADDRESS as string | undefined; +const svmAddress = process.env.SVM_ADDRESS as string | undefined; +const stellarAddress = process.env.STELLAR_ADDRESS as string | undefined; +const tvmAddress = process.env.TVM_ADDRESS as string | undefined; +const xrplAddress = process.env.XRPL_ADDRESS as string | undefined; + +// Validate at least one address is provided +if ( + !avmAddress && + !cardanoAddress && + !aptosAddress && + !ccdAddress && + !evmAddress && + !svmAddress && + !keetaAddress && + !nearAddress && + !stellarAddress && + !hederaAddress && + !tvmAddress && + !xrplAddress +) { + console.error( + "❌ At least one of AVM_ADDRESS, APTOS_ADDRESS, CARDANO_ADDRESS, CCD_ADDRESS, EVM_ADDRESS, KEETA_ADDRESS, NEAR_ADDRESS, SVM_ADDRESS, STELLAR_ADDRESS, HEDERA_ACCOUNT_ID, TVM_ADDRESS, or XRPL_ADDRESS is required", + ); + process.exit(1); +} + +const facilitatorUrl = process.env.FACILITATOR_URL; +if (!facilitatorUrl) { + console.error("❌ FACILITATOR_URL environment variable is required"); + process.exit(1); +} + +// Network configuration +const AVM_NETWORK = (process.env.AVM_NETWORK || ALGORAND_TESTNET_CAIP2) as Network; // Algorand Testnet +const CARDANO_NETWORK = "cardano:preprod" as const; // Cardano Preprod Testnet +const APTOS_NETWORK = (process.env.APTOS_NETWORK || APTOS_TESTNET_CAIP2) as Network; // Aptos Testnet +const CCD_NETWORK = "ccd:4221332d34e1694168c2a0c0b3fd0f27" as const; // Concordium Testnet +const EVM_NETWORK = "eip155:84532" as const; // Base Sepolia +const HEDERA_NETWORK = "hedera:testnet" as const; // Hedera Testnet +const KEETA_NETWORK = KEETA_TESTNET_CAIP2; // Keeta Testnet +const NEAR_NETWORK = (process.env.NEAR_NETWORK || NEAR_TESTNET_CAIP2) as Network; // NEAR Testnet +const SVM_NETWORK = "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1" as const; // Solana Devnet +const STELLAR_NETWORK = "stellar:testnet" as const; // Stellar Testnet +const HEDERA_HBAR_ASSET = "0.0.0" as const; // Native HBAR asset id +const HEDERA_WEATHER_PRICE_TINYBARS = "100000" as const; // 0.001 HBAR +const TVM_NETWORK = (process.env.TVM_NETWORK || "tvm:-3") as Network; // TON Testnet +const XRPL_NETWORK = (process.env.XRPL_NETWORK || XRPL_TESTNET) as Network; // XRPL Testnet +const CCD_WEATHER_PRICE_MICRO_CCD = "1000" as const; // 0.001 CCD + +// Build accepts array dynamically based on configured addresses +const accepts: Array<{ + scheme: string; + price: Price; + network: Network; + payTo: string; +}> = []; +if (avmAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: AVM_NETWORK, + payTo: avmAddress, + }); +} +if (aptosAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: APTOS_NETWORK, + payTo: aptosAddress, + }); +} +if (cardanoAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: CARDANO_NETWORK, + payTo: cardanoAddress, + }); +} +if (ccdAddress) { + accepts.push({ + scheme: "exact", + price: { + amount: CCD_WEATHER_PRICE_MICRO_CCD, + asset: "CCD", + }, + network: CCD_NETWORK, + payTo: ccdAddress, + }); +} +if (evmAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: EVM_NETWORK, + payTo: evmAddress, + }); +} +if (hederaAddress) { + accepts.push({ + scheme: "exact", + price: { + amount: HEDERA_WEATHER_PRICE_TINYBARS, + asset: HEDERA_HBAR_ASSET, + }, + network: HEDERA_NETWORK, + payTo: hederaAddress, + }); +} +if (keetaAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: KEETA_NETWORK, + payTo: keetaAddress, + }); +} +if (nearAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: NEAR_NETWORK, + payTo: nearAddress, + }); +} +if (svmAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: SVM_NETWORK, + payTo: svmAddress, + }); +} +if (stellarAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: STELLAR_NETWORK, + payTo: stellarAddress, + }); +} +if (tvmAddress) { + accepts.push({ + scheme: "exact", + price: "$0.001", + network: TVM_NETWORK, + payTo: tvmAddress, + }); +} +if (xrplAddress) { + accepts.push({ + scheme: "exact", + price: { + amount: process.env.XRPL_AMOUNT || "1000", + asset: "XRP", + }, + network: XRPL_NETWORK, + payTo: xrplAddress, + }); +} + +// Create facilitator client +const facilitatorClient = new HTTPFacilitatorClient({ url: facilitatorUrl }); + +// Create x402 resource server and register schemes dynamically +const server = new x402ResourceServer(facilitatorClient); +if (avmAddress) { + server.register(AVM_NETWORK, new ExactAvmScheme()); +} +if (aptosAddress) { + server.register(APTOS_NETWORK, new ExactAptosScheme()); +} +if (cardanoAddress) { + server.register(CARDANO_NETWORK, new ExactCardanoScheme()); +} +if (ccdAddress) { + server.register(CCD_NETWORK, new ExactConcordiumScheme()); +} +if (evmAddress) { + server.register(EVM_NETWORK, new ExactEvmScheme()); +} +if (hederaAddress) { + server.register(HEDERA_NETWORK, new ExactHederaScheme()); +} +if (keetaAddress) { + server.register(KEETA_NETWORK, new ExactKeetaScheme()); +} +if (nearAddress) { + server.register(NEAR_NETWORK, new ExactNearScheme()); +} +if (svmAddress) { + server.register(SVM_NETWORK, new ExactSvmScheme()); +} +if (stellarAddress) { + server.register(STELLAR_NETWORK, new ExactStellarScheme()); +} +if (tvmAddress) { + server.register(TVM_NETWORK, new ExactTvmScheme()); +} +if (xrplAddress) { + server.register(XRPL_NETWORK, new ExactXrplScheme()); +} + +// Create Express app +const app = express(); + +// Apply payment middleware +app.use( + paymentMiddleware( + { + "GET /weather": { + accepts, + description: "Weather data", + mimeType: "application/json", + }, + }, + server, + ), +); + +// Protected endpoint +app.get("/weather", (req, res) => { + res.send({ + report: { + weather: "sunny", + temperature: 70, + }, + }); +}); + +// Health check (no payment required) +app.get("/health", (req, res) => { + res.send({ status: "ok" }); +}); + +// Start server +const port = process.env.PORT || 4021; +app.listen(port, () => { + console.log(`🚀 All Networks Server listening at http://localhost:${port}`); + if (avmAddress) { + console.log(` AVM: ${avmAddress} on ${AVM_NETWORK}`); + } + if (aptosAddress) { + console.log(` Aptos: ${aptosAddress} on ${APTOS_NETWORK}`); + } + if (cardanoAddress) { + console.log(` Cardano: ${cardanoAddress} on ${CARDANO_NETWORK}`); + } + if (ccdAddress) { + console.log(` CCD: ${ccdAddress} on ${CCD_NETWORK}`); + } + if (evmAddress) { + console.log(` EVM: ${evmAddress} on ${EVM_NETWORK}`); + } + if (hederaAddress) { + console.log(` Hedera: ${hederaAddress} on ${HEDERA_NETWORK}`); + } + if (keetaAddress) { + console.log(` Keeta: ${keetaAddress} on ${KEETA_NETWORK}`); + } + if (nearAddress) { + console.log(` NEAR: ${nearAddress} on ${NEAR_NETWORK}`); + } + if (svmAddress) { + console.log(` SVM: ${svmAddress} on ${SVM_NETWORK}`); + } + if (stellarAddress) { + console.log(` Stellar: ${stellarAddress} on ${STELLAR_NETWORK}`); + } + if (tvmAddress) { + console.log(` TVM: ${tvmAddress} on ${TVM_NETWORK}`); + } + if (xrplAddress) { + console.log(` XRPL: ${xrplAddress} on ${XRPL_NETWORK}`); + } + console.log(` Facilitator: ${facilitatorUrl}`); + console.log(); +}); diff --git a/specs/schemes/exact/scheme_exact_cardano.md b/specs/schemes/exact/scheme_exact_cardano.md index 889beb5532..84b8c3232d 100644 --- a/specs/schemes/exact/scheme_exact_cardano.md +++ b/specs/schemes/exact/scheme_exact_cardano.md @@ -14,6 +14,8 @@ It offers different assetTransferMethods to do x402 interactions: **Masumi vs. Script.** These two script-based methods serve different purposes. **Masumi** is the *concrete* agent-to-agent case: a specific deployed contract (`vested_pay`) with a fixed 19-field datum and a defined escrow lifecycle, so x402 knows the datum shape and validates it. **Script** is the *general* case: the server defines whatever contract it wants and supplies whatever datum that contract needs. Because that datum is arbitrary and contract-specific, x402 **cannot** validate its correctness — it verifies only that `payTo` is the declared script's address and attaches the datum verbatim. Use Masumi for agent payments; use Script to lock into your own contract. +**Why `assetTransferMethod` is part of the scheme and not an x402 extension.** The x402 specification reserves the two fields for different jobs: `PaymentRequirements.extra` is "scheme-specific additional information", while `extensions` carries "modular optional functionality beyond core payment mechanics". Extensions are *ignorable by construction* — servers advertise them, clients may echo them, and a payment completes correctly even when a client omits them entirely (the reference resource server explicitly passes validation in that case). The transfer method has the opposite property: it defines **where the value goes and what the payment transaction is**. A client that ignored `masumi` would pay the script address with no datum and permanently strand the funds (`vested_pay` validates nothing at lock time); a facilitator that ignored it would approve exactly such stranding locks; a server that ignored it could not declare the escrow at all. Every party has to act on the method, which makes it normative payload semantics — scheme territory, versioned with the scheme. This is also the established cross-chain pattern: EVM's `exact` scheme selects between `eip3009`, `permit2`, and `erc7710` via `extra.assetTransferMethod`, XRPL's between `sequence` and `ticketSequence`, and each facilitator implementation branches its verification on the selected method exactly as the Cardano facilitator does here. The extensions that exist in this repository (Bazaar discovery, gas sponsoring) decorate an already-chosen transfer mechanism; none defines how the asset moves. + ## Network Identifiers The canonical network identifiers for this scheme are `cardano:mainnet`, `cardano:preprod`, and `cardano:preview`. These are the only forms advertised in the `/supported` response. @@ -42,35 +44,44 @@ sequenceDiagram %% Client Prepares Payment Note over Client: 3. Client selects payment option,
creates and
signs a Transaction + %% Optional Client Submission + opt Client Submission + Client->>Cardano: 4a. Submit signed transaction + Cardano-->>Client: 4b. Transaction accepted by mempool or block + end + %% Request with Payment - Client->>Server: 4. HTTP GET /api
Header: PAYMENT-SIGNATURE (signed transaction) + Client->>Server: 5. HTTP GET /api
Header: PAYMENT-SIGNATURE (signed transaction) Note right of Client: Retries with payment header %% Server Verification alt Server Verification - Server->>Server: 5. Verify transaction locally + Server->>Server: 6. Verify transaction locally else Remote Verification (via Facilitator) - Server->>Facilitator: 5. POST /verify
(Payment Payload + Requirements) + Server->>Facilitator: 6. POST /verify
(Payment Payload + Requirements) Note right of Facilitator: Facilitator validates:
- Payment amount
- Correct recipient
- Nonce in Transaction end - %% Server Verification - alt Server Submission - Server->>Cardano: 6a. Submit signed transaction + %% Settlement + alt Client Submission + Server->>Cardano: 7a. Query exact submitted transaction + Cardano-->>Server: 7b. Transaction hash + confirmation + else Server Submission + Server->>Cardano: 7a. Submit signed transaction Note right of Cardano: Transaction included in mempool or block - Cardano-->>Server: 6b. Transaction hash + confirmation + Cardano-->>Server: 7b. Transaction hash + confirmation else Remote Submission (via Facilitator) - Server->>Facilitator: 6a. POST /settle
(Payment details) - Facilitator->>Cardano: 6b. Submit signed transaction + Server->>Facilitator: 7a. POST /settle
(Payment details) + Facilitator->>Cardano: 7b. Submit signed transaction Note right of Cardano: Transaction included in mempool or block - Cardano-->>Facilitator: 6c. Transaction hash + confirmation - Facilitator->>Server: 6d. Settlement Response
(txHash, status) + Cardano-->>Facilitator: 7c. Transaction hash + confirmation + Facilitator->>Server: 7d. Settlement Response
(transaction, status) end - Note right of Server: 7. Receives transaction hash and status + Note right of Server: 8. Receives transaction hash and status %% Final Response - Server->>Client: 8. HTTP 200 OK + Resource
Header: PAYMENT-RESPONSE + Server->>Client: 9. HTTP 200 OK + Resource
Header: PAYMENT-RESPONSE Note left of Server: Returns requested resource
with transaction confirmation:
- transaction: "2f9a7b3c..."
- network: "cardano:mainnet"
- success: true ``` @@ -83,32 +94,36 @@ The protocol flow for `exact` on Cardano is client-driven. - If using Address-To-Address payments, the `payTo` field will contain the address to which the payment must be sent. - If using Script payments, the `extra` field will contain parameters to be applied to scripts during transaction building. -3. **Client** constructs the transaction body, signs it, and returns it to the **Resource Server** via the `PAYMENT-SIGNATURE` header. +3. **Client** constructs and signs the transaction. In client mode, it submits the transaction before it sends the paid retry. In server mode, it leaves the transaction unsubmitted. + +4. **Client** returns the signed transaction to the **Resource Server** via the `PAYMENT-SIGNATURE` header. -4. **Resource Server** verifies the transaction is valid: +5. **Resource Server** verifies the transaction is valid: - **Local verification**: The server validates the transaction structure, amount, and recipient address directly. - **Remote verification**: The server forwards the `PAYMENT-SIGNATURE` header and `paymentRequirements` to a **Facilitator's** `/verify` endpoint to check if the transaction is valid. -5. After successful verification, the signed transaction is submitted to the Cardano blockchain: +6. After successful verification, the transaction is settled: + - **Client submission**: The **Resource Server** or **Facilitator** verifies settlement evidence for the exact transaction that the Client already submitted. - **Server submission**: The **Resource Server** submits the transaction directly to the Cardano blockchain. - **Facilitator submission**: The **Resource Server** sends the transaction to the **Facilitator's** `/settle` endpoint, which then submits it to the blockchain. -6. The Cardano blockchain includes the transaction in the mempool or a block and returns the transaction hash and confirmation status. +7. The Cardano blockchain includes the transaction in the mempool or a block and returns the transaction hash and confirmation status. -7. **Resource Server** receives the transaction hash and status: - - If submitted via the **Facilitator**, it receives a settlement response containing the `txHash` and `status`. +8. **Resource Server** receives the transaction hash and status: + - If submitted via the **Facilitator**, it receives a settlement response containing the `transaction` hash and `extra.status`. - Cardano uses Ouroboros Praos, which has probabilistic finality. A transaction that appears in the mempool or even in a recent block can be rolled back. Granting access upon mempool inclusion (`status: "mempool"`) is therefore **strongly discouraged** and SHOULD NOT be used for any resource with real economic value. Servers that choose to accept mempool status MUST document this risk and accept full liability for rolled-back transactions. -8. **Resource Server** grants the **Client** access to the requested resource, returning an HTTP 200 OK response with an `PAYMENT-RESPONSE` header containing: - - `txHash`: The Cardano transaction hash +9. **Resource Server** grants the **Client** access to the requested resource, returning an HTTP 200 OK response with a `PAYMENT-RESPONSE` header containing: + - `success`: Whether settlement succeeded + - `transaction`: The Cardano transaction hash - `network`: The Cardano network (e.g., `cardano:mainnet`) - - `status`: The transaction status (e.g., `confirmed` or `mempool`) + - `extra.status`: The transaction status (e.g., `confirmed` or `mempool`) -### `PaymentRequirementsResponse` +### `PaymentRequired` #### Default Schema -When the Resource Server responds with a `402 Payment Required`, the body of the response contains the payment requirements in the following schema: +When the Resource Server responds with a `402 Payment Required`, it returns the payment requirements as a base64-encoded `PaymentRequired` object in the **`PAYMENT-REQUIRED` response header**. Decoded, the object has the following schema: ```js { @@ -123,11 +138,13 @@ When the Resource Server responds with a `402 Payment Required`, the body of the { "scheme": "exact", "network": "cardano:mainnet", // cardano:preprod or cardano:preview for public testnets - "amount": "10000", // 1 USDM = 1000000000 - "asset": "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d", // ${policyId}.${assetName} The policy id in this example is the USDM policy id on Cardano Mainnet - use 16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde for USDM on Preprod. The asset name is the hex representation of '(333) USDM' + "amount": "10000", // atomic units of `asset`; USDM has 6 decimals, so 1 USDM = 1000000 + "asset": "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d", // ${policyId}.${assetNameHex} — USDM on Cardano Mainnet. BOTH parts differ on Preprod: policy id e675b46e4d2242c991a8932a99db3044e80515ae14b4c4ccf6b3f4c9, asset name 0014df10745553444d. The asset name is the CIP-68 (333) label followed by the hex of 'USDM' (mainnet) / 'tUSDM' (preprod) "payTo": "addr1...", "maxTimeoutSeconds": 600, // Has to be set to a higher amount of time because of the Cardano Network speed "extra": { + "submissionPolicy": "either", // optional; server, client, or either; defaults to server + "confirmationPolicy": { "l1Confirmations": 1 }, // optional; defaults to 1 // In case of default address-to-address payments, this may be empty or contain additional metadata } } @@ -135,21 +152,57 @@ When the Resource Server responds with a `402 Payment Required`, the body of the } ``` +#### Submission and confirmation policy + +`submissionPolicy` controls who submits the signed transaction. For every method, it is an optional field in `PaymentRequirements.extra`. Its values are `server`, `client`, and `either`; omission normalizes to `server`. + +The paid payload MAY contain `submissionMode: "server"` or `submissionMode: "client"`. An absent value normalizes to `server`. The normalized mode MUST match the selected requirements policy. `either` is a policy, not a payload mode. A retry for the same transaction MUST use the same normalized mode. + +| `submissionPolicy` | Allowed normalized `payload.submissionMode` | Submitter | +|---|---|---| +| `server` | `server` | resource server or facilitator | +| `client` | `client` | client | +| `either` | `server` or `client` | party selected by the client | + +In client mode, the client broadcasts before the paid retry. The verifier MUST authenticate settlement evidence for the exact transaction and MUST NOT broadcast it again. In server mode, the resource server or facilitator verifies the transaction before broadcast. + +`/supported` MAY advertise `submissionModes`. The selected policy always comes from the 402 requirements; a client MUST NOT infer it from `/supported`. + +`confirmationPolicy.l1Confirmations` sets the minimum L1 evidence required before the resource is released. It is an integer from `-1` through `20`: + +- `-1` means authenticated mempool acceptance. +- `0` means inclusion in a canonical block. +- `1..20` means that at least that many newer canonical blocks exist. + +An absent confirmation policy normalizes to `{ "l1Confirmations": 1 }`. Greater evidence satisfies a lower threshold. The response reports the strongest verified evidence, not only the minimum. + +For all methods, the policy is a top-level `extra.confirmationPolicy` bound by the selected requirements and exact `accepted` matching. It is not part of Masumi `termsDigest`. Hydra settlement is Masumi-only and uses verified `SnapshotConfirmed` evidence instead of the L1 count. + #### Masumi assetTransferMethod Schema -When the Resource Server requires payment via **Masumi**, the buyer locks the payment into the Masumi escrow contract (`vested_pay`) rather than paying the seller directly. `payTo` is the **Masumi escrow script address** for the network, and `extra` carries the fields needed to construct the escrow **datum**. +> **TL;DR:** The 402 response contains all seller terms. The buyer builds one Masumi V2 lock for one asset and sends it on the paid retry. + +This method supports Masumi V2 only. The buyer locks one requested asset in the deployed V2 `vested_pay` contract. x402 covers only the initial `FundsLocked` output; later Masumi state transitions are outside this scheme (see [Lifecycle boundary](#lifecycle-boundary)). + +The initial request replaces `/start_job` only in the x402 flow. Native MIP-003 agents can continue to use `/start_job`. A resource server can use Masumi Payment Service, another SDK, or its own implementation. -**Scope:** x402 covers only the **lock** step — the escrow's initial `FundsLocked` state. Result submission, seller withdrawal, buyer refund, and dispute resolution all happen later, in **separate transactions** governed by the contract and the Masumi Payment Service, and are out of scope for this scheme. A successful `/settle` for Masumi therefore means **"funds are locked in escrow", not "payment delivered"**: the resource server grants access on the lock and trusts the Masumi lifecycle to release funds to the agent. +In this section, the **requirements issuer** creates the Masumi `PaymentRequirements` and gets the seller authorization. The resource server or a service can fill this role. -The `referenceKey`, `referenceSignature`, nonces, `agentIdentifier`, and the four time bounds originate off-chain — the buyer first creates a purchase with the Masumi Payment Service (for an agent registered in the Masumi Registry), which returns these binding identifiers. They are stored in the datum and, although the on-chain validator does not verify most of them, the Masumi service uses them to match the locked UTxO to the purchase. The resource server therefore **MUST** provide them in `extra` (they are required); the client MUST NOT invent or randomize them, since a value that does not match the purchase yields an escrow the Masumi service cannot settle. Only `inputHash` (defaults to empty) and `collateralReturnLovelace` (defaults to 0) are optional. +The requirements contain one top-level `amount` and one `asset`. This method does not support a Masumi multi-fund payment. + +`amount` MUST be a positive canonical decimal string. `asset` MUST be `lovelace` or the canonical `policyId.assetNameHex` form above. + +The requirements issuer MUST generate a fresh `sellerNonce` for every new requirements object. It MUST use a cryptographically secure random generator. + +It MUST store the complete requirements object and reuse it on the paid retry. It MUST NOT regenerate the nonce, deadlines, commitment, or policies. The resource server compares the stored requirements with the retry's `accepted` object before it calls a facilitator. ```js { "x402Version": 2, "error": "PAYMENT-SIGNATURE header is required", "resource": { - "url": "https://api.example.com/premium-data", - "description": "Access to premium market data", + "url": "https://agent.example.com/weather", + "description": "Agent job", "mimeType": "application/json" }, "accepts": [ @@ -158,71 +211,445 @@ The `referenceKey`, `referenceSignature`, nonces, `agentIdentifier`, and the fou "network": "cardano:preprod", // cardano:mainnet or cardano:preview "amount": "5000000", // amount locked into the escrow (of `asset`) "asset": "lovelace", // lovelace, or a single native token (`policyId.assetNameHex`) - "payTo": "addr_test1w...", // the Masumi `vested_pay` escrow script address for this network + "payTo": "addr_test1w...", // the Masumi `vested_pay` escrow script address for this deployment "maxTimeoutSeconds": 600, // Has to be set to a higher amount of time because of the Cardano Network speed "extra": { "assetTransferMethod": "masumi", - "paymentType": "Web3CardanoV2", // Masumi PaymentSourceType — selects the contract generation (v2 = vested_pay) - "contractAddress": "addr_test1w...", // optional; escrow address for this deployment (defaults to Masumi's canonical address). MUST equal payTo - "sellerAddress": "addr_test1q...", // datum `seller` (full, key-credential address) - "sellerReturnAddress": "addr_test1q...", // optional; datum `seller_return_address` - "buyerReturnAddress": "addr_test1q...", // optional; datum `buyer_return_address` - "referenceKey": "", // datum `reference_key` - "referenceSignature": "", // datum `reference_signature` (>= 16 bytes, unique per UTxO) - "identifierFromPurchaser": "", // datum `buyer_nonce` - "sellerNonce": "", // datum `seller_nonce` - "agentIdentifier": "", // datum `agent_identifier` - "inputHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", // datum `input_hash` - "collateralReturnLovelace": "0", // datum `collateral_return_lovelace` (>= 0) - "payByTime": "1713626260000", // POSIX milliseconds - "submitResultTime": "1713636260000", - "unlockTime": "1713636260000", - "externalDisputeUnlockTime": "1713636260000" + "submissionPolicy": "server", + "confirmationPolicy": { "l1Confirmations": 1 }, + "inputCommitment": { + "version": "1", + "algorithm": "sha256", + "parts": [ + { + "name": "body", + "canonicalization": "jcs", + "mediaType": "application/json", + "content": { "days": 3, "units": "metric" }, + "digest": "<32-byte lowercase hex>" + } + ], + "digest": "<32-byte lowercase hex — equals terms.inputHash>" + }, + "terms": { + "version": "1", + "paymentType": "Web3CardanoV2", + "sellerAddress": "addr_test1q...", // datum `seller` (key-credential address) + "sellerReturnAddress": "addr_test1q...", // optional; omit when absent, `null` is invalid + "sellerNonce": "<32-byte lowercase hex>", + "buyerNonce": "", // empty, or 14–26 lowercase hex characters + "agentIdentifier": "", // omit entirely for an unregistered seller + "inputHash": "", + "payByTime": "1713626260000", // POSIX milliseconds + "submitResultTime": "1713636260000", + "unlockTime": "1713640260000", + "externalDisputeUnlockTime": "1713644260000", + "settlementPolicy": "auto" // auto, l1, or hydra + }, + "referenceKey": "", + "referenceSignature": "", + "blockchainIdentifier": "", + "deployment": { // optional; omit for the canonical deployment + "requiredAdmins": "2", + "adminVkeys": [""], + "cooldownPeriod": "420000" + } } } ] } ``` -The client constructs the escrow **datum** (a Plutus `Constr 0` with the fields below) and attaches it as an **inline datum** on the output paying `payTo`: +`extra`, `inputCommitment`, every commitment part, `terms`, `confirmationPolicy` and `deployment` are **closed objects**: an unknown field is invalid. `terms` **MUST NOT** repeat a field that is projected into `signedTerms` from the top level. `collateral_return_lovelace` is deliberately absent — the seller never supplies or signs it (see [Escrow datum](#escrow-datum-and-client-computed-collateral)). + +Wire constraints for the `extra` fields: + +| Field | Constraint | +|---|---| +| `assetTransferMethod` | literal `masumi` | +| `referenceKey` | lowercase even-length hex of one complete CBOR `COSE_Key` | +| `referenceSignature` | lowercase even-length hex of one complete CBOR `COSE_Sign1` | +| `blockchainIdentifier` | lowercase even-length hex of the complete LZString-compressed compatibility identifier | +| `submissionPolicy` | optional `server`, `client`, or `either`; defaults to `server` | +| `confirmationPolicy.l1Confirmations` | optional JSON integer from `-1` through `20`; defaults to `1` | +| `deployment.requiredAdmins` | positive canonical base-10 integer string, no greater than the length of `adminVkeys` | +| `deployment.adminVkeys` | ordered non-empty array of 28-byte lowercase hex verification-key hashes; duplicates are preserved and carry voting weight (see [Deployment and escrow address](#deployment-and-escrow-address)) | +| `deployment.cooldownPeriod` | non-negative canonical base-10 POSIX-millisecond integer string | + +Constraints for `terms`: + +| Field | Constraint | +|---|---| +| `version` | literal string `1` | +| `paymentType` | literal `Web3CardanoV2`. Any other value MUST be rejected — this field selects the contract generation and is not advisory | +| `sellerAddress` | key-credential Cardano address on the selected network | +| `sellerReturnAddress` | optional key-credential address on the selected network; **omitted** when absent, JSON `null` is invalid | +| `sellerNonce` | exactly 32 fresh cryptographically random bytes as 64 lowercase hex characters | +| `buyerNonce` | empty string, or 7–13 bytes as 14–26 even-count lowercase hex characters | +| `agentIdentifier` | optional `null`, empty string, or non-empty even-length lowercase hex registry asset identifier | +| `inputHash` | exactly equal to `inputCommitment.digest` | +| the four `*Time` fields | positive canonical base-10 POSIX-millisecond strings with no leading zero, satisfying the interval minimums below | +| `settlementPolicy` | `auto`, `l1`, or `hydra` | + +The initial protected-resource request MAY omit a buyer nonce. An API can define one nonce source in the body, parameters, or an application header. The signed `terms.buyerNonce` field is always present and can be empty. The resource server extracts the same source on the paid retry and rejects a mismatch. + +##### Lifecycle boundary + +> **TL;DR:** The protected-resource request is the purchase order. Every `extra` field is issuer-derived; the buyer supplies only datum fields. x402 ends at the `FundsLocked` output. + +**There is no purchase-creation step.** A `masumi` 402 answers the buyer's ordinary protected-resource request — unauthenticated, first contact, no prior handshake and no stored purchase record to look up. The requirements issuer holds that request and derives the requirements from it directly; that is what replaces `/start_job` here. No field in the 402 depends on knowing the caller's identity: [Request commitment](#request-commitment) hashes the request as received instead of MIP-004's `identifierFromPurchaser`-keyed formula, and `terms.buyerNonce` is allowed to be empty for exactly this reason. + +Everything the buyer verifies before locking is issuer-derived: + +| Field | Origin | +|---|---| +| `inputCommitment`, `terms.inputHash` | digest over the request content as the issuer received it | +| `terms.sellerNonce` | fresh CSPRNG value per requirements object | +| the four `*Time` fields | chosen per request, anchored to issuance time | +| `payTo` | derived from `deployment` against the canonical validator, never hand-supplied | +| `referenceKey`, `referenceSignature` | seller authorization over `termsDigest` (see [Seller-signed terms](#seller-signed-terms)) | +| `terms.sellerAddress`, `sellerReturnAddress`, `agentIdentifier` | seller configuration | +| `submissionPolicy`, `confirmationPolicy` | issuer policy | + +The buyer contributes only datum fields, and none of them appear in `extra`: `buyer` is proven by the payment credential controlling `payload.nonce`, `buyer_return_address` is buyer-chosen and deliberately unmatched against `extra`, and `collateral_return_lovelace` is client-computed (see [Lock invariants](#lock-invariants)). Because `extra` and `terms` are closed objects, a buyer-supplied field in either is a rejection. + +Deadlines are issued per request, not per process. A `pay_by_time` fixed once at startup drifts out of its window and the payment is then rejected, since rule 7 bounds the TTL by `maxTimeoutSeconds` while the lock invariants bound the TTL by `pay_by_time`; reusing one requirements object across buyers also collides on `termsDigest` (see [Masumi logical replay](#masumi-logical-replay)). Within a single exchange the opposite applies: the issuer stores the object and replays it verbatim on the paid retry. + +**x402 ends at the `FundsLocked` output.** A settled `masumi` payment means the funds are locked in the escrow under terms both parties signed — not delivered to the seller. Releasing them runs the ordinary Masumi V2 lifecycle, which this scheme neither drives nor constrains: the seller submits a result hash (`ResultSubmitted`), the buyer may request a refund (`RefundRequested`), and a refund against a submitted result makes the escrow `Disputed` and reachable by the deployment's admin keys after `external_dispute_unlock_time` (see [Deployment and escrow address](#deployment-and-escrow-address)). The three later deadlines in the datum govern when each of those paths opens; `vested_pay` defines their exact effect, not this scheme. Masumi Payment Service, another SDK, or the resource server's own implementation drives the transitions. The deadlines and `input_hash` signed into the datum exist so that they, and any later arbitration, have a binding record of the job that was paid for. + +##### Request commitment + +> **TL;DR:** The server returns the exact content that it commits to. The client checks every digest and approves the content in its application context. + +The commitment is built from the **content of the buyer's protected-resource request as the requirements issuer received it** — the parameters, the parsed body, and where needed the raw request bytes — and nothing else. It is what the escrow's `input_hash` binds the locked funds to, so the payment is tied to exactly the job that was requested and a later dispute can be arbitrated against it. This derivation deliberately replaces MIP-004's `SHA256(identifierFromPurchaser + ";" + canonicalJSON(input_data))`: the buyer's nonce is not an input here, which is what allows a `masumi` 402 to answer a first-contact request with no prior handshake. A Masumi Payment Service therefore cannot reproduce an x402 `input_hash` with the MIP-004 formula. + +The requirements issuer returns the exact content it commits to, and the client verifies every digest before it pays. `inputCommitment.parts` is an ordered array with unique `name` values; conventional names are `parameters`, `body` and `raw`, but applications MAY define others. Every part carries: + +- `name` — a unique non-empty string +- `canonicalization` — `jcs` or `raw` +- `mediaType` — optional, preserved byte-for-byte +- `content` — an RFC 8785-compatible JSON value for `jcs`, or an unpadded base64url string for `raw` +- `digest` — lowercase hex `SHA-256(partBytes)`, exactly 64 characters + +For `jcs`, `partBytes = UTF-8(RFC8785-JCS(content))`. For `raw`, `partBytes = base64url-decode(content)`. A `raw` part MUST identify a stable capture point before a parser changes the bytes; for HTTP that is normally the entity body at that point, never the full HTTP message. + +To build the manifest, omit each part's `content` property and the top-level `digest` property — do **not** set them to `null` or an empty value. Keep all other fields and the part order. Then: + +```text +inputHash = SHA-256( + UTF-8("masumi:x402:input:v1\n") || + UTF-8(JCS(manifest)) +) +``` + +**Content echo.** Because the manifest excludes `content` by construction, omitting `content` on the wire does not change `inputHash`. `content` is therefore **REQUIRED** only for parts the issuer originates — resolved parameters, pricing terms, anything the buyer has not already seen — and **OPTIONAL** for parts derived from the client's own request bytes, which the client recomputes from what it sent. This keeps a large request body out of the `PAYMENT-REQUIRED` header without weakening the commitment. + +The client **MUST** recompute every part digest and `inputHash`, using the issuer's `content` where present and its own request bytes where absent, and MUST reject a mismatch. The application **MUST** present issuer-originated content for approval before payment; the facilitator checks digests but cannot judge their application meaning. The resource server rebuilds the commitment from the signed retry and rejects a mismatch. + +The complete `PAYMENT-REQUIRED` header must fit the server's actual transport limit. Servers **MUST NOT** truncate content or replace it with a URL; they SHOULD return an application error such as HTTP 413 when it does not fit, and SHOULD keep `raw` parts small. Servers **MUST NOT** capture secrets, cookies, authorization headers, x402 headers or the complete HTTP request, and MUST NOT log payment headers or committed content. + +##### Seller-signed terms + +> **TL;DR:** The seller signs one digest that covers the price, asset, contract, request hash, identity, deadlines, and settlement layer. + +The seller signs one digest covering the price, asset, contract, request hash, identity, deadlines, and settlement layer. Client and facilitator reconstruct: + +```text +signedTerms = { + ...terms, + scheme: PaymentRequirements.scheme, + assetTransferMethod: extra.assetTransferMethod, + network: PaymentRequirements.network, + contractAddress: PaymentRequirements.payTo, + amount: PaymentRequirements.amount, + asset: PaymentRequirements.asset, + maxTimeoutSeconds: PaymentRequirements.maxTimeoutSeconds +} + +termsDigest = SHA-256( + UTF-8("masumi:x402:terms:v1\n") || + UTF-8(JCS(signedTerms)) +) +``` + +> The field set above is normative for this scheme version. `termsDigest` is only reproducible when both sides agree on it exactly, so any change to the member list is a breaking change to the scheme, not an implementation detail. + +The seller calls [CIP-30](https://cips.cardano.org/cip/CIP-0030) `signData(sellerAddress, lowercaseHex(termsDigest))`; the returned `DataSignature` follows [CIP-8](https://cips.cardano.org/cip/CIP-0008). `referenceKey` carries the complete CBOR `COSE_Key` as lowercase hex and `referenceSignature` the complete CBOR `COSE_Sign1`. The attached payload is the 32-byte `termsDigest`, `hashed` is `false`, and the external AAD is empty. + +Client and facilitator **MUST** decode and verify both COSE objects, checking: + +- `kty = OKP (1)`, `alg = EdDSA (-8)`, `crv = Ed25519 (6)` +- a 32-byte public key with no private material +- protected `COSE_Sign1` headers carrying `alg = EdDSA (-8)` and the raw `sellerAddress` +- an unprotected `hashed = false` header and empty external AAD +- an attached payload equal to `termsDigest` +- a valid Ed25519 `Sig_structure` +- equal `kid` values when both are present +- `Blake2b-224(publicKey)` equal to the seller's payment-key credential + +A `sellerAddress` with a script payment credential is invalid. The last check is what binds the signature to the **address** rather than to an arbitrary key, and MUST NOT be skipped. + +##### Identity and compatibility identifier + +> **TL;DR:** A non-empty signed `agentIdentifier` makes a registry claim. An omitted, `null`, or empty value means that the seller is unregistered. + +A non-empty `agentIdentifier` makes a Masumi registry claim. Its first 56 hexadecimal characters MUST equal the global Masumi V2 registry policy ID `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b`; another policy is not a Masumi V2 registry. The client and facilitator **MUST** validate the asset on the selected network independently — seller authorization, metadata, endpoint, network and price — and a registered price MUST resolve to the signed top-level `amount` and `asset`. A registered price that requires more than one asset is invalid for this scheme. + +An omitted, `null`, or empty `agentIdentifier` means that the seller is unregistered. The datum's `agent_identifier` is empty bytes and no component may claim registry identity or reputation. These forms select the same identity mode, but they remain different signed wire values: client and facilitator reconstruct `signedTerms` without omitting, inserting, or replacing the field. + +The compatibility identifier lets Masumi tooling locate the payment: + +```text +agentIdentifierHex = terms.agentIdentifier when it is a non-empty string, otherwise "" +sellerIdentifierHex = sellerNonceHex + agentIdentifierHex + +identifierText = + sellerIdentifierHex + "." + + buyerNonceHex + "." + + referenceSignatureHex + "." + + referenceKeyHex + "." + + contractAddressBech32 + +blockchainIdentifier = hex(LZString.compressToUint8Array(identifierText)) +``` + +`identifierText` is ASCII-range UTF-8. Implementations **MUST** join the encoded text values before compression and **MUST NOT** hex-decode the first four segments first. The decompressed value has five period-delimited segments; the first 64 characters of segment one are the `sellerNonce` and any remainder is `agentIdentifier`. Segment two MAY be empty and the empty segment MUST be preserved. + +The following encoding-only vectors test the compatibility codec. The short key and signature values are not valid COSE objects. + +**Unregistered seller with an empty buyer nonce** + +| Field | Value | +|---|---| +| `sellerNonceHex` | `11` repeated 32 bytes | +| `agentIdentifierHex` | empty | +| `buyerNonceHex` | empty | +| `referenceSignatureHex` | `55` repeated 16 bytes | +| `referenceKeyHex` | `a10101` | +| `contractAddressBech32` | `addr_test1wzs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgn37w4g` | + +The exact `identifierText` is: + +```text +1111111111111111111111111111111111111111111111111111111111111111..55555555555555555555555555555555.a10101.addr_test1wzs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgn37w4g +``` + +The exact `blockchainIdentifier` is: + +```text +230d7c6574f41d1c0acc96ade8eae04360019f607004d8809c07d005c053019cae007700bce8058680d89818c04e44002c035931a2c00daf5e00ac9bf00b6c401b80473c6535d00e6003cb8b110199db615001ca8eecc6019b58076c603b13763a80 +``` + +**Registered seller** + +| Field | Value | +|---|---| +| `sellerNonceHex` | `22` repeated 32 bytes | +| `agentIdentifierHex` | `aa` repeated 28 bytes, followed by `01` | +| `buyerNonceHex` | `01020304050607` | +| `referenceSignatureHex` | `66` repeated 16 bytes | +| `referenceKeyHex` | `a10102` | +| `contractAddressBech32` | `addr_test1wzs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgn37w4g` | + +The exact `identifierText` is: + +```text +2222222222222222222222222222222222222222222222222222222222222222aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01.01020304050607.66666666666666666666666666666666.a10102.addr_test1wzs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgn37w4g +``` + +The exact `blockchainIdentifier` is: + +```text +130d7c6574f4218314e4b56f46e00602300e972d82c0662c0162c0562c0362c0763d6975b7d8f3b6f3874381e004d0402700fa005c0298067093803b802f19e4a6d05018c02715001601ac154a5006d36680560bb405b4100dc0239611ae64073001eb494192e4700e000e121e70240066610076240c0ae41e400000 +``` + +For each vector, decompression MUST return the exact `identifierText`, including all period delimiters. + +The issuer returns the complete identifier; the client does not construct it for transport. Client and facilitator reconstruct it and require: + +```text +PaymentRequirements.payTo + == signedTerms.contractAddress + == decode(blockchainIdentifier).smartContractAddress + == the transaction's escrow output address +``` + +##### Escrow datum and client-computed collateral + +> **TL;DR:** The wallet builds the 19-field datum and computes structural lovelace from the final transaction. The seller does not provide collateral. + +The client constructs the escrow **datum** — a Plutus `Constr 0` with the 19 ordered fields below — and attaches it as an **inline datum** on the output paying `payTo`. The scheme-level datum schema version is `masumi.vested_pay.v2`. | # | Datum field | Value at lock | Source | |---|-------------|---------------|--------| -| 0 | `buyer` | the **payer**'s address (key-credential) | client wallet | -| 1 | `buyer_return_address` | `None`, or an address | `extra.buyerReturnAddress` | -| 2 | `seller` | seller address (key-credential) | `extra.sellerAddress` | -| 3 | `seller_return_address` | `None`, or an address | `extra.sellerReturnAddress` | +| 0 | `buyer` | key address whose payment credential controls the `payload.nonce` input | client wallet | +| 1 | `buyer_return_address` | `None`, or a buyer key address | **client** — buyer-chosen, never declared | +| 2 | `seller` | seller key address | `terms.sellerAddress` | +| 3 | `seller_return_address` | `None`, or a seller key address | `terms.sellerReturnAddress` | | 4 | `reference_key` | bytes | `extra.referenceKey` | -| 5 | `reference_signature` | bytes, length ≥ 16, unique per script UTxO | `extra.referenceSignature` | -| 6 | `seller_nonce` | bytes | `extra.sellerNonce` | -| 7 | `buyer_nonce` | bytes | `extra.identifierFromPurchaser` | -| 8 | `agent_identifier` | bytes | `extra.agentIdentifier` | -| 9 | `collateral_return_lovelace` | integer ≥ 0 | `extra.collateralReturnLovelace` | -| 10 | `input_hash` | bytes | `extra.inputHash` | +| 5 | `reference_signature` | bytes, length ≥ 16 | `extra.referenceSignature` | +| 6 | `seller_nonce` | bytes | `terms.sellerNonce` | +| 7 | `buyer_nonce` | bytes, possibly empty | `terms.buyerNonce` | +| 8 | `agent_identifier` | bytes, empty when unregistered | `terms.agentIdentifier` | +| 9 | `collateral_return_lovelace` | integer ≥ 0 | **client calculation** | +| 10 | `input_hash` | 32 bytes | `terms.inputHash` | | 11 | `result_hash` | **empty** | — | -| 12 | `pay_by_time` | POSIX ms | `extra.payByTime` | -| 13 | `submit_result_time` | POSIX ms | `extra.submitResultTime` | -| 14 | `unlock_time` | POSIX ms | `extra.unlockTime` | -| 15 | `external_dispute_unlock_time` | POSIX ms | `extra.externalDisputeUnlockTime` | +| 12 | `pay_by_time` | POSIX ms | `terms.payByTime` | +| 13 | `submit_result_time` | POSIX ms | `terms.submitResultTime` | +| 14 | `unlock_time` | POSIX ms | `terms.unlockTime` | +| 15 | `external_dispute_unlock_time` | POSIX ms | `terms.externalDisputeUnlockTime` | | 16 | `seller_cooldown_time` | `0` | — | | 17 | `buyer_cooldown_time` | `0` | — | | 18 | `state` | `FundsLocked` | — | -Because the `vested_pay` validator only runs on spend (never on the lock itself), a malformed datum is **not** rejected at lock time — it silently strands the funds, and the Masumi Payment Service validates the lock **off-chain**. Clients and facilitators MUST therefore enforce, to parity with that off-chain validation, for the lock: +Implementations MUST build the following Plutus Data structures. They MUST NOT encode a Cardano address as Bech32 text or as raw address bytes: + +| Type | Plutus Data encoding | +|---|---| +| `Address` | `Constr 0 [paymentCredential, stakeCredentialOption]` | +| payment or stake `VerificationKey` credential | `Constr 0 [Bytes(28-byte key hash)]` | +| payment or stake `Script` credential | `Constr 1 [Bytes(28-byte script hash)]` | +| `Option
.Some(address)` | `Constr 0 [address]` | +| `Option
.None` | `Constr 1 []` | +| stake credential `Some(Inline(credential))` | `Constr 0 [Constr 0 [credential]]` | +| stake credential `Some(Pointer(slot, txIndex, certIndex))` | `Constr 0 [Constr 1 [Int(slot), Int(txIndex), Int(certIndex)]]` | +| stake credential `None` | `Constr 1 []` | +| every byte-string field | `Bytes` containing the decoded bytes, not hexadecimal text | +| every time, cooldown and lovelace field | `Int` | +| `FundsLocked` | `Constr 0 []` | + +`buyer`, `seller` and both return addresses use **verification-key** payment credentials under this scheme, even though the CIP-57 `Address` type can represent script credentials. + +**Encoding test vector.** Enterprise addresses, `None` for both return addresses: buyer payment-key hash `11` × 28, seller `22` × 28, `reference_key` `a10101`, `reference_signature` `55` × 16, `seller_nonce` `33` × 32, empty `buyer_nonce` / `agent_identifier` / `result_hash`, `collateral_return_lovelace` `1435230`, `input_hash` `44` × 32, deadlines `1785756000000` / `1785759600000` / `1785763200000` / `1785766800000`, both cooldowns `0`, state `FundsLocked`. The ledger Plutus Data CBOR is: + +```text +d8799fd8799fd8799f581c11111111111111111111111111111111111111111111111111111111ffd87a80ffd87a80d8799fd8799f581c22222222222222222222222222222222222222222222222222222222ffd87a80ffd87a8043a1010150555555555555555555555555555555555820333333333333333333333333333333333333333333333333333333333333333340401a0015e65e58204444444444444444444444444444444444444444444444444444444444444444401b0000019fc75a1f001b0000019fc7910d801b0000019fc7c7fc001b0000019fc7feea800000d87980ff +``` + +An implementation MUST preserve the same Plutus Data tree when it decodes and re-encodes this CBOR; raw CBOR byte equality is not required. + +**Collateral.** The seller never supplies or signs `collateral_return_lovelace` — the client computes it after it has selected its final addresses and output shape, from the requested asset and live protocol parameters. Let `requestedLovelace` be the top-level `amount` for a lovelace payment, or `0` for a native-token payment. The escrow output MUST satisfy: + +```text +lockedLovelace = requestedLovelace + collateral_return_lovelace +``` + +`collateral_return_lovelace` MUST be `0` or at least **1,435,230**, and MUST be chosen so that `lockedLovelace` also clears the protocol min-UTXO of the datum **after `SubmitResult`** (a 32-byte `result_hash` and non-zero cooldowns) — otherwise the seller can never spend the escrow. For a **native-token** payment `requestedLovelace` is `0`, so a zero collateral cannot satisfy both rules and `collateral_return_lovelace` MUST therefore be at least the larger of 1,435,230 and that post-`SubmitResult` minimum. The token quantity is exact and the output carries no other native token; structural lovelace is the only extra value. Client and facilitator calculate the minimum independently. + +##### Deployment and escrow address + +The escrow address is deployment-specific: the validator parameters (`required_admins_multi_sig`, `admin_vks`, `cooldown_period`) are baked into the script hash, so a different parameterization yields a different address and the hash alone cannot be checked against the un-applied blueprint. This scheme uses the canonical CIP-57 blueprint from [`masumi-payment-service`](https://github.com/masumi-network/masumi-payment-service/blob/d74b2c319228bcbef36632de37875c388dcee7ce/smart-contracts/payment-v2/plutus.json): -- `buyer` and `seller` are **public-key** (not script) credential addresses, and `buyer` equals the transaction's payer. -- `state` is `FundsLocked`, `result_hash` is empty, and **both cooldown timers are `0`**; the escrow output carries **no reference script**. -- `pay_by_time ≤ submit_result_time ≤ unlock_time ≤ external_dispute_unlock_time`, and the transaction's validity upper bound (TTL) is on/before `pay_by_time` so the lock cannot settle after the deadline. -- `reference_signature` is at least 16 bytes. -- `collateral_return_lovelace` is `0` or ≥ **1,435,230**, does **not** exceed the locked lovelace (a collateral above the locked ADA bricks the seller's on-chain spend), and — for a lovelace payment — the locked lovelace covers `amount + collateral_return_lovelace`. -- The escrow output holds enough lovelace for the protocol min-UTXO of the datum **after `SubmitResult`** (a 32-byte `result_hash` and non-zero cooldowns), not merely at lock time — otherwise the seller can never spend. -- `buyer_return_address` / `seller_return_address` match the declared values exactly (declared ⇒ present in the datum; omitted ⇒ `None`), and the escrow output carries **exactly** the requested asset set (no extra native tokens). +| Property | Canonical value | +|---|---| +| datum schema | `masumi.vested_pay.v2` | +| CIP-57 validator title | `vested_pay.vested_pay.spend` | +| Plutus version | `v3` | +| blueprint digest | `SHA-256(JCS(blueprint))` = `6249de17bb87c5246106af6b0f33de22b44ca24b9c1445fa36d10eb8b583dec7` | +| default `requiredAdmins` | `2` | +| default ordered `adminVkeys` | `fc16a1fcf309aed03ec18bb2176f5ea29acea70bb79145ebaffa8e75`, `7f78161369549d8e2b138fee724c9fa606d6107a66720bdb4c48ada6`, `89eef9ea84e0ee7fe4921fa93eb2873ff6e34473f751d5d52cb75aa6` | +| default `cooldownPeriod` | `420000` ms | -The escrow script address is deployment-specific: the validator parameters (`required_admins_multi_sig`, `admin_vks`, `cooldown_period`) are baked into the script hash, so a different parameterization (e.g. a self-hosted Masumi) yields a different address, and the hash alone cannot be checked against the un-applied blueprint. The resource server therefore declares its deployment's escrow address in `extra.contractAddress` (from the purchase); when omitted it defaults to Masumi's canonical address for the network. A facilitator MUST verify that `payTo` equals that declared escrow address before accepting a Masumi payment — this binds the lock to the specific escrow the purchase expects, since a look-alike `vested_pay` with different admins is a different trust domain. +When `extra.deployment` is absent, the verifier applies these default parameters. When it is present, it replaces **only** those three applied parameters against the same canonical compiled validator, preserving order and duplicate admin hashes. Either way the verifier derives the validator hash and network address itself and **MUST** require the derived address to equal `payTo`. `payTo` is never defaulted or inferred: it is signed into `signedTerms` as `contractAddress` and a mismatch is a rejection, since a look-alike `vested_pay` with different admins is a different trust domain. Preview has no canonical default and therefore requires `extra.deployment`. + +An application **MUST** explicitly allow a non-default parameter set; the seller signature alone is not approval. Custom parameters need no second copy inside `signedTerms` — applying them changes the validator hash, the address contains that hash, and the signed `payTo` binds the deployment. Use the generic `script` method for any other validator or datum layout. + +**What the admin keys can do.** `admin_vks` and `required_admins_multi_sig` define the escrow's dispute arbitrators, and choosing a deployment is choosing them. Their authority is deliberately narrow, but within it, absolute: + +- They can settle a **`Disputed`** escrow, and only that, through the `WithdrawDisputed` redeemer. No other redeemer in `vested_pay` consults `admin_vks`, so a `FundsLocked`, `ResultSubmitted` or `RefundRequested` escrow is beyond their reach — normal completion and normal refund never involve them. An escrow becomes `Disputed` only on genuine conflict: the buyer has requested a refund *and* the seller has submitted a result hash. +- They cannot act before `external_dispute_unlock_time`, and they do not submit the settling transaction themselves. Each admin produces a CIP-8 signature over `blake2b_224(cbor(DisputeWithdrawal { own_ref, buyer_value, seller_value }))`; **anyone** may then build and land the transaction. Because `own_ref` is inside the signed payload, a signature authorizes exactly one UTxO and cannot be replayed against another — including against another deployment of the same validator. +- Settlement requires the number of `admin_vks` entries carrying a valid signature to reach `required_admins_multi_sig`. +- The signed `buyer_value` and `seller_value` are **minimum** payouts, not exact amounts. Any escrow value above their sum is unconstrained on-chain and accrues to whoever submits the transaction — an intentional finder's fee so a third party will cover the settlement cost. There is no on-chain cap on that residual: within a dispute, the admin signature set is trusted completely. Since anyone can send assets to a script address, the residual can also grow after the signatures are produced. + +**Weighted voting.** `admin_vks` MAY repeat a key, and a key appearing *n* times counts *n* times toward the threshold — one physical signature filling several slots. This is intended by the contract, not a deployment bug. It means a raw count misrepresents the real authority: `requiredAdmins: 2` over `adminVkeys: [x, x, y]` is not "2 of 3", it is "`x` alone can settle". A client or wallet that surfaces a deployment for approval **MUST** present the **effective weight per distinct key** and the threshold as a fraction of total weight, never the raw array length. An issuer modelling a weighted authority MUST construct `adminVkeys` with the intended duplication. + +##### Lock invariants + +Because the `vested_pay` validator only runs on spend (never on the lock itself), a malformed datum is **not** rejected at lock time — it silently strands the funds. Facilitators MUST therefore enforce, and clients SHOULD validate before signing: + +- `buyer` and `seller` are **public-key** (not script) credential addresses. +- The payment credential in `buyer` **controls the input named by `payload.nonce`**, and the transaction carries its valid witness. This identifies the buyer without assuming a single owner controls every input. +- No datum address is the escrow itself: `buyer`, `seller` and both return addresses MUST differ from the escrow address. `vested_pay` re-parses every output at the script address as a continuation datum (`expect new_datum: Datum`), so a payout aimed back at the escrow aborts every spend path — and Masumi's own decoder (`decodeV2ContractDatum`) rejects such a datum outright. +- The effective buyer payout target (`buyer_return_address`, else `buyer`) MUST differ from the effective seller payout target (`seller_return_address`, else `seller`); this scheme does not allow aggregated payouts. +- `state` is `FundsLocked`, `result_hash` is empty, **both cooldown timers are `0`**, and the escrow output carries **no reference script**. +- The deadlines are ordered and clear these minimum intervals: `pay_by_time + 5 min ≤ submit_result_time`, `submit_result_time + 15 min ≤ unlock_time`, `unlock_time + 15 min ≤ external_dispute_unlock_time`. +- At issuance `pay_by_time` is in the future and does not exceed the issuance time plus `maxTimeoutSeconds`; `submit_result_time` is at least 15 minutes in the future. The issuer MUST choose `maxTimeoutSeconds` large enough for its settlement expectations. +- The transaction's validity upper bound (TTL) is on or before `pay_by_time`, so the lock cannot settle after the deadline. +- The datum carries the exact COSE key and signature bytes, and `reference_signature` is at least 16 bytes. +- The collateral and value rules above hold, and the escrow output carries **exactly** the requested asset set. +- `seller_return_address` matches the signed terms exactly (declared ⇒ present in the datum with matching credentials; omitted ⇒ `None`). `buyer_return_address` is buyer-chosen and is **not** matched against `extra`. + +##### Settlement and confirmation policy + +> **TL;DR:** Masumi selects L1 or Hydra. Shared optional policies select the submitter and L1 evidence. Defaults are `auto`, `server`, and one confirmation. + +Masumi supports Cardano L1 and Hydra settlement. `settlementPolicy` is `auto`, `l1`, or `hydra`. `auto` uses a suitable Hydra head when the client supports one and otherwise uses L1. `l1` forces L1. `hydra` requires a suitable head and does not allow fallback. + +A **suitable Hydra head** is open and has a verified on-chain Init state on the selected Cardano network. Its contestation period, protocol parameters, and unique participant keys MUST be verified. The participant set MUST match an established binding between the seller and its Hydra participant. The head-opening process can establish this binding; the seller does not need to sign the head ID again in the x402 terms. + +The seller or its authorized operator MUST be able to submit later V2 lifecycle transactions and to close, contest, and fan out the head. An unverified `HeadIsOpen` event or client-supplied metadata is not sufficient. + +The paid payload contains `settlementLayer: "l1"` or `settlementLayer: "hydra"`. A Hydra payment also contains `headId`, the canonical lowercase 56-character hexadecimal Hydra protocol head ID from the on-chain Init transaction. It MUST NOT be a database ID, service-local name, or connection identifier. `headId` MUST be absent for L1. + +Masumi uses the shared [submission and confirmation policy](#submission-and-confirmation-policy). Both fields remain in top-level `extra` and are not seller-signed. + +`confirmationPolicy.l1Confirmations` is an integer from `-1` through `20`: + +- `-1` means authenticated mempool acceptance. +- `0` means inclusion in a canonical block. +- `1..20` means that at least that many newer canonical blocks exist. + +The default is `1`. These values are minimum evidence levels: canonical inclusion satisfies `-1`, and any greater canonical depth satisfies a lower L1 threshold. A client-submitted transaction that has left the mempool can therefore settle from canonical block evidence. Hydra requires a verified `SnapshotConfirmed` from the selected head. + +The requirements issuer applies the Masumi settlement default before calculating `termsDigest`. It MUST include `settlementPolicy` in `extra.terms`. It normalizes top-level `extra.submissionPolicy` and `extra.confirmationPolicy` separately when it builds the requirements. + +```mermaid +flowchart TD + Quote["Masumi settlement, submission, and confirmation policies"] --> Layer{"Resolve settlement layer"} + Layer -->|l1| L1["Cardano L1"] + Layer -->|hydra| Hydra["Selected open Hydra head"] + Layer -->|auto| Auto{"Suitable supported head?"} + Auto -->|yes| Hydra + Auto -->|no| L1 + L1 --> Submit{"Resolve submission mode"} + Hydra --> Submit + Submit -->|client| Client["Client broadcasts before paid retry"] + Submit -->|server| Service["Resource server or facilitator broadcasts"] + Client --> Evidence["Verify settlement evidence"] + Service --> Evidence + Evidence --> Success["Return resource after threshold is met"] +``` + +An internal or external facilitator MAY advertise Masumi capabilities in the matching `/supported` entry: + +```json +{ + "kinds": [ + { + "x402Version": 2, + "scheme": "exact", + "network": "cardano:preprod", + "extra": { + "assetTransferMethods": ["masumi"], + "settlementLayers": ["l1", "hydra"], + "submissionModes": ["server", "client"], + "l1Confirmations": { + "server": { "minimum": -1, "maximum": 20 }, + "client": { "minimum": 0, "maximum": 20 } + } + } + } + ], + "extensions": [], + "signers": {} +} +``` + +`/supported` describes available capabilities. The 402 response carries the selected policies. When `submissionPolicy` is `either`, the service MUST support every selectable submission-mode and settlement-layer combination. For L1, the selected confirmation level MUST be within both mode ranges. Otherwise the issuer MUST return separate requirements for `server` and `client` instead of `either`. #### Script assetTransferMethod Schema -When the Resource Server requires payment to a script, the `extra` field in the `PaymentRequirementsResponse` contains additional fields required for script interactions. +When the Resource Server requires payment to a script, the `extra` field in the `PaymentRequired` object contains additional fields required for script interactions. ```js { @@ -237,12 +664,14 @@ When the Resource Server requires payment to a script, the `extra` field in the { "scheme": "exact", "network": "cardano:mainnet", // cardano:preprod or cardano:preview for public testnets - "amount": "10000", // 1 USDM = 1000000000 - "asset": "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d", // ${policyId}.${assetName} The policy id in this example is the USDM policy id on Cardano Mainnet - use 16a55b2a349361ff88c03788f93e1e966e5d689605d044fef722ddde for USDM on Preprod. The asset name is the hex representation of '(333) USDM' + "amount": "10000", // atomic units of `asset`; USDM has 6 decimals, so 1 USDM = 1000000 + "asset": "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d", // ${policyId}.${assetNameHex} — USDM on Cardano Mainnet. BOTH parts differ on Preprod: policy id e675b46e4d2242c991a8932a99db3044e80515ae14b4c4ccf6b3f4c9, asset name 0014df10745553444d "payTo": "addr1...", // In case of script payments, this is the script address (the address should match the script provided in extra after applying parameters. In case of additional parameters provided, the client needs to pass the additional parameters to the server in the PAYMENT-SIGNATURE header, so that the server can reconstruct the script address and verify the payment) "maxTimeoutSeconds": 600, // Has to be set to a higher amount of time because of the Cardano Network speed "extra": { "assetTransferMethod": "script", // optional, can be "default" | "masumi" | "script" + "submissionPolicy": "either", // optional; server, client, or either; defaults to server + "confirmationPolicy": { "l1Confirmations": 1 }, // optional; defaults to 1 // If the script assetTransferMethod is used, make sure to include all script related fields "scriptHash": "script_hash_here", // If the script is already on-chain, provide its hash and the client can resolve the full script "script": { @@ -263,7 +692,7 @@ When the Resource Server requires payment to a script, the `extra` field in the } ``` -**Datum.** A contract that expects a datum on its locked UTxO declares one in `extra.datum` as **CBOR hex**; the client attaches it to the `payTo` output as an **inline datum**. This is what makes the script method able to lock funds into a real contract (most validators require a datum to be spendable — an output at a PlutusV1/V2 script address with no datum is permanently unspendable, and a PlutusV3 validator only spends a datum-less output if it was written for the `None` case). Omit `datum` only for scripts that spend without one. +**Datum.** A contract that expects a datum on its locked UTxO declares one in `extra.datum` as **CBOR hex**; the client attaches it to the `payTo` output as an **inline datum**. This is what makes the script method able to lock funds into a real contract (most validators require a datum to be spendable — an output at a Plutus V2 script address with no datum is permanently unspendable, and a Plutus V3 validator only spends a datum-less output if it was written for the `None` case; for Plutus V1 this method cannot produce a spendable datum-bearing lock at all — see the caveat below). Omit `datum` only for scripts that spend without one. Because the datum is arbitrary and contract-specific, **the facilitator does not verify its contents** — it cannot know what an unknown contract expects. The facilitator enforces only that `payTo` is the script address implied by `script`/`parameters` (or `scriptHash`) and attaches the datum as declared. **Providing a datum the target validator accepts is the server's responsibility**: a wrong or missing datum strands the locked funds, and x402 will not catch it. The datum is attached **inline** (PlutusV2/V3); datum-**hash** outputs (needed to later spend a PlutusV1 script) are out of scope for this method. @@ -273,12 +702,17 @@ The PAYMENT-SIGNATURE header is base64-encoded and sent in the client's request The payload field of the PAYMENT-SIGNATURE header must contain the following fields: -transaction: The signed Cardano transaction (Base64 encoded). +- `transaction`: The signed Cardano transaction (Base64 encoded). +- `nonce`: A UTXO reference (`txHash#index`) that is also one of the transaction's inputs. This is the replay guard the facilitator enforces (Verification Rule 5), so a payload without it MUST be rejected. +- `submissionMode`: Optional `server` or `client`. An absent value normalizes to `server`. + Example: ```js { - "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=" + "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=", + "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0", + "submissionMode": "server" } ``` @@ -300,12 +734,15 @@ Full PAYMENT-SIGNATURE header: "payTo": "addr1...", "maxTimeoutSeconds": 600, "extra": { + "submissionPolicy": "either", + "confirmationPolicy": { "l1Confirmations": 1 }, // In case of default address-to-address payments, this may be empty or contain additional metadata } }, "payload": { - "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=" - "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0" + "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=", + "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0", + "submissionMode": "server" } } ``` @@ -331,28 +768,51 @@ Expanded Schema based on assetTransferMethods: "maxTimeoutSeconds": 600, "extra": { "assetTransferMethod": "masumi", - "paymentType": "Web3CardanoV2", - "sellerAddress": "addr_test1q...", - "referenceKey": "", - "referenceSignature": "", - "identifierFromPurchaser": "", - "sellerNonce": "", - "agentIdentifier": "", - "inputHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", - "collateralReturnLovelace": "0", - "payByTime": "1713626260000", - "submitResultTime": "1713636260000", - "unlockTime": "1713636260000", - "externalDisputeUnlockTime": "1713636260000" + "submissionPolicy": "either", + "confirmationPolicy": { "l1Confirmations": 1 }, + "inputCommitment": { + "version": "1", + "algorithm": "sha256", + "parts": [ + { + "name": "body", + "canonicalization": "jcs", + "mediaType": "application/json", + "content": { "days": 3, "units": "metric" }, + "digest": "<32-byte lowercase hex>" + } + ], + "digest": "<32-byte lowercase hex>" + }, + "terms": { + "version": "1", + "paymentType": "Web3CardanoV2", + "sellerAddress": "addr_test1q...", + "sellerNonce": "<32-byte lowercase hex>", + "buyerNonce": "", + "inputHash": "", + "payByTime": "1713626260000", + "submitResultTime": "1713636260000", + "unlockTime": "1713640260000", + "externalDisputeUnlockTime": "1713644260000", + "settlementPolicy": "auto" + }, + "referenceKey": "", + "referenceSignature": "", + "blockchainIdentifier": "" } }, "payload": { - "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=" - "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0" + "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=", + "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0", + "settlementLayer": "l1", + "submissionMode": "client" } } ``` +For `settlementLayer: "hydra"`, `payload.headId` is required. It MUST be absent for L1. `submissionMode` follows the shared submission policy. An absent value normalizes to `server`; it is never `either`. + #### Script assetTransferMethod ```js @@ -372,6 +832,8 @@ Expanded Schema based on assetTransferMethods: "maxTimeoutSeconds": 600, "extra": { "assetTransferMethod": "script", + "submissionPolicy": "either", + "confirmationPolicy": { "l1Confirmations": 1 }, "scriptHash": "script_hash_here", "script": { "type": "plutusV3", @@ -384,8 +846,9 @@ Expanded Schema based on assetTransferMethods: } }, "payload": { - "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=" - "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0" + "transaction": "AAAIAQDi1HwjSnS6M+WGvD73iEyUY2FRKNj0MlRp7+3SHZM3xCvMdB0AAAAAIFRgPKOstGBLCnbcyGoOXugUYAWwVzNrpMjPCzXK4KQWAQCMoE29VLGwftex8rhIlOuFLFNfxLIJlHqGXoXA8hx6l+LMdB0AAAAAIHbPucTRIEWgO6lzqukswPZ6i72IHEKK5LyM1l9HJNZNAQBthSeHDVK8Xr5/zp3JMZPLtG5uAoVgedTA4pEnp+h8qUlUzRwAAAAAIACH0swYW/QfGCFczGnjAVPHPqZrQE5vfvJr36i6KVEFAQAC7W4K5vCwB+nprjxcNlLiOQ7SIIfyCZjmj2qSis2iTsCuzBwAAAAAIAkSUkXOoeq52GNdhwpbs+jZqqrqPdmiN3oPw5EzDIanAQAIyFNGWD6OxiFIyXSxrNEcFG0npm+nImk6InUssXb1EZgx1hwAAAAAILhsjmMKyM0n75Cd7z6ufH2LNhOMibFOGhNlLgV5RFuEAQC+Mh4kGkLwrw/11729oUQnt3xOmOreE6PcnuN6M68ZBcCuzBwAAAAAIO2PQhSSqSAawCbRr005lfjBgFOqIHo4zb2GcQ/WCxAlAAgA+QKVAAAAAAAgjiAHD0X4HNSdVPpJtf2E6W2uRc8kbvCHYkgEQ1B+w1MDAwEAAAUBAQABAgABAwABBAABBQACAQAAAQEGAAEBAgEAAQcAHrfFfj8r0Pxsudz/0UPqlX5NmPgFw1hzP3be4GZ/4LEB5XXrONxGw0qOUsq3yNKeUhOCOgCIwaa4pswKaer66EKqPGwdAAAAACBrOIN4poutFUmHfB6FbFJu8GgXoPPTGQWREqFpPfvO1B63xX4/K9D8bLnc/9FD6pV+TZj4BcNYcz923uBmf+Cx7gIAAAAAAABg4xYAAAAAAAA=", + "nonce": "662cbf645fcd8914eb89115b83970a950493dd2fbaf39dea3b96e8cbdc132939#0", + "submissionMode": "client" } } ``` @@ -402,26 +865,35 @@ A facilitator MUST enforce all of the following rules before accepting a payment 4. **Asset Verification**: The asset unit in the transaction MUST exactly match `PaymentRequirements.asset` (format: `${policyId}.${assetNameHex}`). The facilitator MUST NOT accept a different asset, even one of equal market value. -5. **Nonce / Replay Prevention**: The `payload.nonce` MUST be a valid UTXO reference (`txHash#index`) that is included as an input in the transaction. The facilitator MUST verify that this UTXO exists in the current on-chain UTXO set and has not been spent. This ensures uniqueness and prevents replay attacks. +5. **Nonce / Replay Prevention**: The `payload.nonce` MUST be a valid UTXO reference (`txHash#index`) included as an input in the transaction. The selected settlement ledger is Cardano L1 unless a Masumi payload selects Hydra. In server mode, before submission, the facilitator MUST verify that the nonce is unspent in the selected ledger: the current L1 UTXO set for L1, or the authenticated current UTXO state of the verified `headId` for Hydra. In client mode, authenticated settlement evidence MUST prove that the exact submitted transaction consumed the nonce in that same ledger. Hydra evidence requires a verified `SnapshotConfirmed` transition for the exact transaction and head; an unauthenticated `GetUTxO`, `HeadIsOpen` event, or snapshot from another head is not sufficient. This ensures uniqueness without requiring a Hydra UTXO to exist on L1. -6. **TTL / Expiry Check**: The transaction's TTL (time-to-live slot) MUST not have already passed at the time of verification. The facilitator MUST reject transactions whose TTL is in the past. The TTL SHOULD be consistent with `PaymentRequirements.maxTimeoutSeconds`. +6. **Submission Check**: An absent `payload.submissionMode` normalizes to `server`. The normalized mode MUST match `submissionPolicy`. In server mode, the facilitator submits only after verification, and MUST first run complete ledger **phase-1 validation** of the signed transaction — Plutus script evaluation alone is not sufficient, because it admits unbalanced and unauthenticated transactions. In client mode, it MUST verify authenticated evidence for the exact transaction and MUST NOT submit it again. `/supported` MUST advertise only the submission modes the facilitator can actually perform; a facilitator without a phase-1 validator does not offer `server`. -7. **Minimum UTXO Check**: The output paying `payTo` SHOULD carry at least the protocol minimum lovelace for its serialized size, `(160 + |serialized_output|) * coinsPerUtxoByte` (see [Minimum UTXO Value](#minimum-utxo-value-min-ada)). An output below this minimum yields a transaction the node rejects at submission, so the facilitator SHOULD reject it during `verify()` rather than let `settle()` fail. Because `coinsPerUtxoByte` is governance-settable, the facilitator MUST read it from live protocol parameters. A facilitator without access to live protocol parameters MAY skip this check and rely on the node to reject an undersized output at submission. +7. **TTL / Expiry Check**: Before first submission, the transaction's TTL (time-to-live slot) MUST not have passed. The TTL MUST NOT be later than the slot corresponding to the current network time plus `PaymentRequirements.maxTimeoutSeconds`. Both client and facilitator MUST convert wall-clock time to a slot using the current system-start and era summary; they MUST NOT compare seconds against slots as raw values, and MUST NOT assume one slot per second (this holds only from Shelley onward, and is a protocol parameter rather than a constant). After authenticated evidence proves that the selected ledger accepted the transaction within its validity interval, later confirmation checks MAY continue after the TTL. + +8. **Minimum UTXO Check**: The output paying `payTo` SHOULD carry at least the protocol minimum lovelace for its serialized size, `(160 + |serialized_output|) * coinsPerUtxoByte` (see [Minimum UTXO Value](#minimum-utxo-value-min-ada)). An output below this minimum yields a transaction the node rejects at submission, so the facilitator SHOULD reject it during `verify()` rather than let `settle()` fail. Because `coinsPerUtxoByte` is governance-settable, the facilitator MUST read it from live protocol parameters. A facilitator without access to live protocol parameters MAY skip this check and rely on the node to reject an undersized output at submission. + +9. **Confirmation Check**: For L1 settlement, authenticated evidence MUST meet `confirmationPolicy.l1Confirmations`. Canonical inclusion satisfies `-1`; greater canonical depth satisfies a lower threshold. A client-submitted transaction that has left the mempool MAY settle from canonical block evidence. The resource MUST NOT be released before the threshold is met. **Masumi assetTransferMethod — additional rules.** When `requirements.extra.assetTransferMethod` is `masumi`, the facilitator MUST additionally enforce (rule 2's "recipient" is the escrow output paying `payTo`): -- `payTo` equals the deployment's **Masumi escrow (`vested_pay`) script address** — `extra.contractAddress` when present, else Masumi's canonical address for the network. -- The output paying `payTo` carries an **inline datum** decoding to the lock datum with `state == FundsLocked`, empty `result_hash`, and **both cooldown timers `0`**, and the output carries **no reference script**. -- `buyer` equals the transaction's payer and `seller` equals `extra.sellerAddress`; both are **public-key** credential addresses. -- `reference_key`, `reference_signature`, `seller_nonce`, `buyer_nonce`, `agent_identifier`, `input_hash`, `collateral_return_lovelace`, and the four time bounds in the datum match the corresponding `extra` values, and `reference_signature` is at least 16 bytes. -- `buyer_return_address` / `seller_return_address` match `extra` **exactly**: a value declared in `extra` MUST be present in the datum with matching credentials, and one omitted from `extra` MUST be `None` in the datum. -- **Value.** For the requested `asset`: **lovelace** MAY be overpaid but the locked lovelace MUST be ≥ `amount + collateral_return_lovelace`; a **native token** MUST match `amount` exactly. The escrow output MUST carry **exactly** the requested asset set — no extra native tokens (zero tokens for a lovelace payment, exactly the one requested token otherwise). `collateral_return_lovelace` MUST be `0` or ≥ **1,435,230** and MUST NOT exceed the locked lovelace. -- **Deadline.** The transaction MUST carry a validity upper bound (TTL) whose slot time is on/before `pay_by_time`. -- **Minimum UTXO.** The escrow output MUST hold enough lovelace for the protocol min-UTXO of the datum **after `SubmitResult`** (32-byte `result_hash` + non-zero cooldowns), so the seller's later spend stays above min-UTXO. +- **Schema.** `extra`, `inputCommitment`, each commitment part, `terms`, `confirmationPolicy` and `deployment` validate as **closed objects**; an unknown field, a `terms` field that duplicates a projected top-level field, or a missing required field is a rejection. `paymentType` MUST be `Web3CardanoV2`; any other value is rejected rather than ignored. +- **Commitment.** Every part digest and `inputCommitment.digest` recompute correctly, and `terms.inputHash` equals `inputCommitment.digest`. +- **Seller authorization.** `signedTerms` reconstructs from `terms` plus the projected `PaymentRequirements` fields, `termsDigest` recomputes, and both COSE objects decode and verify against it — including `Blake2b-224(publicKey)` equal to the seller's payment-key credential. A `sellerAddress` with a script payment credential is rejected. +- **Escrow address.** The verifier applies the canonical (or explicitly allowed custom) deployment parameters to the canonical blueprint, derives the validator hash and network address itself, and requires it to equal `payTo`. `payTo` MUST also equal `signedTerms.contractAddress` and the contract address decoded from `blockchainIdentifier`. There is **exactly one** escrow output at `payTo`. +- **Identity.** A non-empty `terms.agentIdentifier` MUST start with the global Masumi V2 registry policy ID `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b` and requires independent validation on the selected network (asset, seller authorization, metadata, endpoint, network, exact price). The registered price resolves to the signed `amount`/`asset`. An omitted, `null`, or empty value requires empty datum agent bytes and makes no registry claim. +- The output paying `payTo` carries an **inline datum** decoding against the `masumi.vested_pay.v2` schema, with `state == FundsLocked`, empty `result_hash`, and **both cooldown timers `0`**; the output carries **no reference script**. +- `seller` equals `terms.sellerAddress`, and the payment credential in `buyer` **controls the input named by `payload.nonce`** with a valid witness present. Both are **public-key** credential addresses. +- Neither participant nor either return address equals the escrow address, and the effective buyer payout target differs from the effective seller payout target (see the lock invariants above). +- `reference_key`, `reference_signature`, `seller_nonce`, `buyer_nonce`, `agent_identifier`, `input_hash` and the four time bounds in the datum match the signed terms exactly, and `reference_signature` is at least 16 bytes. `buyer_return_address` is buyer-chosen and is **not** matched; `seller_return_address` matches the signed terms exactly (declared ⇒ present with matching credentials; omitted ⇒ `None`). +- **Value.** `lockedLovelace` equals `requestedLovelace + collateral_return_lovelace`, where `requestedLovelace` is `amount` for a lovelace payment and `0` for a native-token payment; a native token MUST match `amount` exactly. The escrow output MUST carry **exactly** the requested asset set — no extra native tokens. `collateral_return_lovelace` MUST be `0` or ≥ **1,435,230**, and MUST be large enough that `lockedLovelace` clears the post-`SubmitResult` min-UTXO. +- **Deadline.** The transaction MUST carry a validity upper bound (TTL) whose slot time is on/before `pay_by_time`, and the four deadlines MUST clear the minimum intervals in the lock invariants. +- **Settlement.** `settlementPolicy` MUST allow `payload.settlementLayer`. For L1, the nonce and exact transaction MUST have authenticated evidence at or above `confirmationPolicy`: mempool or stronger canonical evidence for `-1`, and canonical inclusion/depth for `0..20`. For Hydra server submission, the nonce MUST be unspent in the authenticated current UTXO state of the verified head before broadcast. For either submission mode, a verified `SnapshotConfirmed` transition for the canonical protocol `headId` MUST prove that the exact transaction consumed that nonce. The verifier also validates the suitable head and seller-participant binding. +- **Minimum UTXO.** The escrow output MUST hold enough lovelace for the protocol min-UTXO of the datum **after `SubmitResult`** (32-byte `result_hash` + non-zero cooldowns), so the seller's later spend stays above min-UTXO. Rule 8's carve-out applies: a facilitator without live protocol parameters MAY skip this check and rely on the node's own min-UTXO rejection at submission. A single Masumi payment locks **one** asset — lovelace, or one native token plus its structural lovelace (which covers the collateral and min-UTXO). `PaymentRequirements` carries a single `asset`/`amount`, so a multi-asset basket is out of scope for this scheme. -A valid Masumi settlement means the funds are **locked in the escrow**, not delivered to the seller; releasing them is governed by the contract and the Masumi Payment Service in later transactions outside this scheme. +A valid Masumi settlement means the funds are **locked in the escrow**, not delivered to the seller; releasing them is governed by the contract in later transactions outside this scheme. **Script assetTransferMethod — additional rules.** When `requirements.extra.assetTransferMethod` is `script`, the facilitator MUST: @@ -429,6 +901,8 @@ A valid Masumi settlement means the funds are **locked in the escrow**, not deli The facilitator **MUST NOT** be expected to validate `extra.datum`: the contract is server-defined and its datum is arbitrary, so no general facilitator can judge whether the datum is correct for the target validator. The client attaches `extra.datum` to the `payTo` output verbatim as an inline datum, and the facilitator passes it through unverified. Consequently the **server owns datum correctness** — a datum the contract does not accept (or a missing datum for a contract that requires one) strands the locked funds, and this scheme provides no on-chain or facilitator guard against it. A facilitator MAY optionally reject a script payment whose `payTo` output carries no inline datum when it has reason to require one, but this is not mandated because some scripts spend without a datum. +**Plutus V1 caveat.** This method attaches datums **inline only**, but the ledger cannot represent an inline datum in a Plutus V1 script context — an output carrying an inline datum at a V1 script address cannot be spent by that script. Servers SHOULD NOT declare a `plutusV1` script together with `extra.datum` under this method (a V1 contract that requires a datum needs a datum-*hash* output, which is out of scope here), and a facilitator MAY reject that combination outright rather than let the funds strand. + ### `PAYMENT-RESPONSE` Header Payload The `PAYMENT-RESPONSE` header is base64-encoded and returned to the client by the resource server. @@ -441,20 +915,34 @@ Schema: { "success": true, // true or false "network": "cardano:mainnet", - "transaction": "2f9a7b3c..." // Transaction hash of the payment if successful + "transaction": "2f9a7b3c...", // Transaction hash of the payment if successful "extra": { - "status": "confirmed", // "confirmed" is the recommended value; "mempool" is permitted but strongly discouraged — see settlement warning above - } + "status": "confirmed", // "mempool" when authenticated mempool evidence is strongest + "submissionMode": "server", + "confirmations": 1 + }, // Optional error field in case of failure "errorReason": "Utxo not found in utxo set" // Example error reason } ``` +The response reports the strongest verified evidence. Before block inclusion, `status` is `mempool` and `confirmations` is `-1`. After inclusion, `status` is `confirmed` and `confirmations` is the actual number of newer canonical blocks. That number MUST meet the selected policy before `success` is `true`. + +For Masumi Hydra settlement, `extra` instead contains `settlementLayer: "hydra"`, the canonical protocol `headId`, `submissionMode`, and `status: "snapshotConfirmed"`. Masumi L1 responses contain `settlementLayer: "l1"` in addition to the shared fields. + +#### Pending confirmation + +If a valid transaction has not reached the required evidence level, the resource server MAY keep the request open or return HTTP 402 with `success: false`, `errorReason: "payment_pending"`, the canonical transaction ID in `extra.transactionId`, and `extra.status: "pending"`. Before block inclusion, `confirmations` is absent; after inclusion, it reports the current depth below the threshold. + +The server MAY include `Retry-After`. A paid retry MUST repeat the exact original `PAYMENT-SIGNATURE`. The client MUST NOT build another transaction. The server resumes observation of the same canonical transaction ID and MUST NOT submit it again. The protected operation MUST be idempotent if it can run before settlement reaches the required evidence. + +If the protected handler runs before final settlement, the resource server MUST store its result and return that result on an identical paid retry. For default and script, it keys the record by canonical Cardano transaction ID. For Masumi, it keys the record by `termsDigest` and binds that digest to the first canonical transaction ID. The record also binds the protected-request fingerprint. A mismatched transaction or fingerprint is a conflict and MUST NOT run the handler again. + ## Transaction Fees The **client** constructs and signs the complete transaction (Protocol Flow step 3). The Cardano network fee is a field of the transaction body, balanced against the client's own inputs — so the **client pays the fee**, alongside the asset being transferred. -The **facilitator** only broadcasts the already-signed transaction to the network. Broadcasting consumes none of the facilitator's funds, so a facilitator does **not** require a funded wallet — only a provider connection for UTXO/slot queries and transaction submission. A facilitator MAY expose an address (e.g. in the `/supported` response) for observability, but it is not used to pay or sign. +The selected client, resource server, or facilitator broadcasts the already-signed transaction. Broadcasting consumes none of the submitter's funds. A facilitator does **not** require a funded wallet, only a provider connection for UTXO/slot queries and transaction submission. A facilitator MAY expose an address in `/supported` for observability, but it is not used to pay or sign. **Fee sponsorship** (the facilitator paying the fee on the client's behalf) is **not supported** by this scheme version. It is achievable on Cardano through collaborative, multi-party transaction building — the facilitator contributing an input to cover the fee and co-signing the transaction — but that requires a different, interactive construction flow than the client-builds-and-signs model specified here, and is left to a future extension. @@ -489,12 +977,44 @@ Cardano's eUTXO model provides strong replay protection once a payment settles: Merchants and/or Facilitators SHOULD maintain a short-term, in-memory cache of transaction payloads that are currently being settled. Before proceeding with settlement, the merchant/facilitator checks whether the transaction has already been seen: -1. After verification succeeds, derive a cache key from the transaction payload (e.g., the base64-encoded signed transaction string). -2. If the key is already present in the cache, reject the settlement with a `"duplicate_settlement"` error. +1. After verification succeeds, derive the cache key from the **canonical Cardano transaction ID** computed over the transaction body. The key MUST NOT be the complete signed CBOR or its encoding: witness sets and equally valid CBOR encodings differ without changing the ledger transaction ID, so an encoding-level key is trivially bypassed by re-serializing the same logical transaction. +2. If the key is already present in the cache, return its cached outcome or await the in-flight first submission, and reject the duplicate settlement with a `"duplicate_settlement"` error rather than executing the protected operation twice. 3. If the key is not present, insert it into the cache — atomically, before the first `await` on submission, so concurrent calls cannot both pass the check — and proceed with submission. -4. Evict entries older than ~120 seconds. The on-chain nonce spend is the durable replay guard, so the cache only needs to cover the unconfirmed window; a transaction whose TTL has already passed can no longer land regardless. +4. Retain each entry until the transaction's TTL has passed **plus** the implementation's confirmation and rollback grace period. A shorter fixed timeout (e.g. a flat 120 seconds) reopens the race while the transaction can still land. + +The claim SHOULD be released only after it is established that no submission occurred, or after a definitive ledger rejection, so a legitimate retry can re-attempt. A timeout, transport failure, unknown node result or mempool-only result MUST retain the claim; the service then reconciles by transaction ID. + +This approach requires no external storage — only an in-process map with time-based eviction. It preserves the facilitator's otherwise stateless design while closing the duplicate settlement attack vector. Note that the cache is per-process: across multiple facilitator instances it does not deduplicate, but the on-chain nonce spend (Rule 5) remains the authoritative cross-instance replay guard. + +### Replay challenge + +A resource server MUST put an opaque challenge in every Cardano 402, bound to the request and to the requirements it quotes: + +```js +"extensions": { + "cardanoReplayProtection": { + "challenges": { "": "<32-byte lowercase hex>" } + } +} +``` + +`requirementsFingerprint` is lowercase hex `SHA-256(UTF-8(RFC8785-JCS(entry)))` over the `accepts` entry it keys. The paid retry echoes the same object in `paymentPayload.extensions`. A challenge stays valid for at least `maxTimeoutSeconds`; the first canonical transaction that uses it consumes it and it then retries only that transaction. + +The server MUST require the echoed challenge when `payload.submissionMode` is `client`, or whenever it cannot bind the request to an authenticated requester. It MAY waive it for an authenticated requester in server mode. Request headers alone do not authenticate a requester. + +### Implementation limits + +An implementation MAY bound what it will process — transaction size and input count, inline script and datum size, script parameter count, commitment part count and content size, admin-key count — and MUST reject beyond its budget rather than process it. These budgets are implementation policy, not ledger rules. + +### Masumi logical replay + +Transaction-level deduplication is **not sufficient** for `masumi`. A client can build several *different* transactions from the same 402 — different inputs, change, or fee — each carrying a valid escrow datum for the same seller terms. Every one of them passes verification and locks funds, and the tx-ID cache above catches none of them, so the buyer pays repeatedly for one job. -The claim SHOULD be released if submission fails with a transient error, so a legitimate retry can re-attempt; it SHOULD be retained on success (including `mempool`-only inclusion) so retries cannot rebroadcast the same transaction. +`masumi` therefore has a logical replay key in addition to the transaction ID: **`termsDigest`**, which by construction covers the price, asset, contract, request commitment, identity and deadlines of exactly one issued 402. -This approach requires no external storage or long-lived state — only an in-process map with time-based eviction. It preserves the facilitator's otherwise stateless design while closing the duplicate settlement attack vector. Note that the cache is per-process: across multiple facilitator instances it does not deduplicate, but the on-chain nonce spend (Rule 5) remains the authoritative cross-instance replay guard. +- The requirements issuer SHOULD bind each `termsDigest` to the **first** durably claimed transaction ID, atomically. A retry carrying the same transaction is idempotent; a *different* transaction for the same digest is a conflict and MUST NOT start further work, even if it lands earlier in canonical ledger order. +- Once claimed, a `termsDigest` SHOULD stay bound to that transaction ID, including after rejection or expiry. A failed payment needs new requirements with a fresh `sellerNonce` — reusing the terms would reuse the signature. +- The facilitator MAY cache `termsDigest` → transaction ID → outcome as an advisory check, retained through the signed validity window plus the confirmation and rollback grace period. +- The verified `blockchainIdentifier` is a compatibility lookup key for the same record; it does not replace the `termsDigest` binding. +Every additional valid lock for one `termsDigest` is a duplicate deposit and remains governed by the Masumi V2 refund and withdrawal paths — x402 does not recover it. diff --git a/typescript/package.json b/typescript/package.json index ecc7873661..a37a79e50d 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -17,7 +17,7 @@ "lint:check": "turbo run lint:check", "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/hedera --filter @x402/keeta --filter @x402/near --filter @x402/stellar --filter @x402/cardano --filter @x402/xrpl test:integration", + "test:integration": "pnpm --filter @x402/core --filter @x402/evm --filter @x402/svm --filter @x402/avm --filter @x402/aptos --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" }, "keywords": [], @@ -31,4 +31,4 @@ "turbo": "^2.5.0", "typescript": "^5.7.3" } -} \ No newline at end of file +} diff --git a/typescript/packages/core/src/client/x402Client.ts b/typescript/packages/core/src/client/x402Client.ts index 11219d78ba..1d5e64a846 100644 --- a/typescript/packages/core/src/client/x402Client.ts +++ b/typescript/packages/core/src/client/x402Client.ts @@ -419,7 +419,7 @@ export class x402Client { const partialPayload = await schemeNetworkClient.createPaymentPayload( paymentRequired.x402Version, requirements, - { extensions: paymentRequired.extensions }, + { extensions: paymentRequired.extensions, resource: paymentRequired.resource }, ); let paymentPayload: PaymentPayload; diff --git a/typescript/packages/core/src/http/x402HTTPResourceServer.ts b/typescript/packages/core/src/http/x402HTTPResourceServer.ts index 478de2d84a..a603ba40e2 100644 --- a/typescript/packages/core/src/http/x402HTTPResourceServer.ts +++ b/typescript/packages/core/src/http/x402HTTPResourceServer.ts @@ -19,6 +19,7 @@ import { Network, PaymentRequirements, } from "../types"; +import { deepEqual } from "../utils"; import { x402Version } from ".."; export const SETTLEMENT_OVERRIDES_HEADER = "Settlement-Overrides"; @@ -76,9 +77,16 @@ export interface HTTPAdapter { * Get the parsed request body * Framework adapters should parse JSON/form data appropriately * - * @returns The parsed request body + * @returns The parsed request body, synchronously or asynchronously */ - getBody?(): unknown; + getBody?(): unknown | Promise; + + /** + * Get the exact request-body bytes without consuming the request stream. + * + * @returns Exact request-body bytes, or undefined when unavailable + */ + getRawBody?(): Uint8Array | undefined | Promise; } /** @@ -280,6 +288,8 @@ export interface HTTPTransportContext { responseBody?: Buffer; /** Response headers set by the route handler (used for settlement overrides) */ responseHeaders?: Record; + /** Status selected by the protected handler. */ + responseStatus?: number; } /** @@ -290,6 +300,7 @@ export interface HTTPResponseInstructions { headers: Record; body?: unknown; // e.g. Paywall for web browser requests, but could be any other type isHtml?: boolean; // e.g. if body is a paywall, then isHtml is true + isRaw?: boolean; // body is already encoded bytes/text and must not be JSON encoded } /** @@ -573,6 +584,7 @@ export class x402HTTPResourceServer { !paymentPayload ? "Payment required" : undefined, extensions, transportContext, + paymentPayload ?? undefined, ); // If no payment provided @@ -615,6 +627,28 @@ export class x402HTTPResourceServer { }; } + // `PaymentPayload.resource` is client-carried. When present, bind it to + // the canonical resource computed for this request before any scheme or + // registry validator uses it. Older clients may omit the optional field. + if ( + this.ResourceServer.requiresMatchingPayloadResource(matchingRequirements) && + paymentPayload.resource !== undefined && + !deepEqual(paymentPayload.resource, resourceInfo) + ) { + const errorResponse = await this.ResourceServer.createPaymentRequiredResponse( + requirements, + resourceInfo, + "Payment resource does not match the protected resource", + extensions, + transportContext, + paymentPayload, + ); + return { + type: "payment-error", + response: this.createHTTPResponse(errorResponse, false, paywallConfig), + }; + } + const extensionResult = this.ResourceServer.validateExtensions( paymentRequired, paymentPayload, @@ -650,9 +684,11 @@ export class x402HTTPResourceServer { transportContext, paymentPayload, ); + const response = this.createHTTPResponse(errorResponse, false, paywallConfig); + if (verifyResult.httpStatus !== undefined) response.status = verifyResult.httpStatus; return { type: "payment-error", - response: this.createHTTPResponse(errorResponse, false, paywallConfig), + response, }; } @@ -862,14 +898,16 @@ export class x402HTTPResourceServer { return { type: "payment-error", response: { - status: 200, + status: skipHandlerResponse?.status ?? 200, headers: { + ...skipHandlerResponse?.headers, "Content-Type": contentType, ...settleResult.headers, "Cache-Control": withPrivateCacheControl(null), }, body, isHtml: contentType.includes("text/html"), + isRaw: skipHandlerResponse?.isRaw, }, }; } diff --git a/typescript/packages/core/src/server/x402ResourceServer.ts b/typescript/packages/core/src/server/x402ResourceServer.ts index 88a9ed71a0..e33418df9e 100644 --- a/typescript/packages/core/src/server/x402ResourceServer.ts +++ b/typescript/packages/core/src/server/x402ResourceServer.ts @@ -82,12 +82,20 @@ export interface VerifyResultContext extends VerifyContext { * (e.g. cooperative refund). Travels in-process only — never on the facilitator wire. */ export interface SkipHandlerDirective { + /** Original successful handler status, when replaying a stored result. */ + status?: number; contentType?: string; body?: unknown; + /** Additional original handler headers safe to replay. */ + headers?: Record; + /** Send `body` as bytes/text instead of JSON encoding it. */ + isRaw?: boolean; } export type ResourceVerifyRespone = VerifyResponse & { skipHandler?: SkipHandlerDirective; + /** Optional transport status selected by a local after-verify guard. */ + httpStatus?: number; }; export interface VerifyFailureContext extends VerifyContext { @@ -141,7 +149,7 @@ export type AfterVerifyHook = ( ) => Promise< | void | { skipHandler: true; response?: SkipHandlerDirective } - | { abort: true; reason: string; message?: string } + | { abort: true; reason: string; message?: string; status?: number } >; export type OnVerifyFailureHook = ( @@ -354,6 +362,22 @@ export class x402ResourceServer { return !!findByNetworkAndScheme(this.registeredServerSchemes, scheme, network); } + /** + * Whether the matched scheme binds a client-carried resource to this request. + * + * @param requirements - Matched payment requirements. + * @returns True when the registered scheme requires exact resource matching. + */ + requiresMatchingPayloadResource(requirements: PaymentRequirements): boolean { + return ( + findByNetworkAndScheme( + this.registeredServerSchemes, + requirements.scheme, + requirements.network as Network, + )?.requireMatchingPayloadResource === true + ); + } + /** * Returns the decimal precision for the asset specified in the given payment requirements. * Looks up the registered scheme for the network and delegates to its getAssetDecimals @@ -849,6 +873,7 @@ export class x402ResourceServer { const context: SchemePaymentRequiredContext = { requirements: workingAccepts, + requirement: accept, paymentPayload, resourceInfo, error, @@ -1454,6 +1479,7 @@ export class x402ResourceServer { isValid: false, invalidReason: directive.reason, invalidMessage: directive.message, + httpStatus: directive.status, }; } if (directive && "skipHandler" in directive && directive.skipHandler) { diff --git a/typescript/packages/core/src/types/mechanisms.ts b/typescript/packages/core/src/types/mechanisms.ts index 9c09b35903..ccbec79ee6 100644 --- a/typescript/packages/core/src/types/mechanisms.ts +++ b/typescript/packages/core/src/types/mechanisms.ts @@ -45,11 +45,13 @@ export type PaymentPayloadResult = Pick; + /** Protected resource the payment is being created for. */ + resource?: ResourceInfo; } export interface SchemeClientHooks { @@ -173,6 +175,8 @@ export type SchemeEnrichSettlementResponseHook = ( export interface SchemePaymentRequiredContext { requirements: PaymentRequirements[]; + /** Requirement currently being enriched by the matched scheme implementation. */ + requirement: PaymentRequirements; paymentPayload?: DeepReadonly; resourceInfo: ResourceInfo; error?: string; @@ -187,6 +191,8 @@ export type SchemeEnrichPaymentRequiredResponseHook = ( export interface SchemeNetworkServer { readonly scheme: string; readonly schemeHooks?: SchemeServerHooks; + /** Require an optional client-carried resource to equal the current protected resource. */ + readonly requireMatchingPayloadResource?: boolean; enrichPaymentRequiredResponse?: SchemeEnrichPaymentRequiredResponseHook; enrichSettlementPayload?: SchemeEnrichSettlementPayloadHook; enrichSettlementResponse?: SchemeEnrichSettlementResponseHook; diff --git a/typescript/packages/core/test/mocks/cash/index.ts b/typescript/packages/core/test/mocks/cash/index.ts index c84b512fa6..f724de421e 100644 --- a/typescript/packages/core/test/mocks/cash/index.ts +++ b/typescript/packages/core/test/mocks/cash/index.ts @@ -43,7 +43,7 @@ export class CashSchemeNetworkClient implements SchemeNetworkClient { network: requirements.network, payload: { signature: `~${this.payer}`, - validUntil: (Date.now() + requirements.maxTimeoutSeconds).toString(), + validUntil: (Date.now() + requirements.maxTimeoutSeconds * 1000).toString(), name: this.payer, }, accepted: requirements, diff --git a/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts b/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts index e1e8619c28..60189ced28 100644 --- a/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts +++ b/typescript/packages/core/test/mocks/generic/MockSchemeClient.ts @@ -1,4 +1,8 @@ -import { SchemeClientHooks, SchemeNetworkClient } from "../../../src/types/mechanisms"; +import { + PaymentPayloadContext, + SchemeClientHooks, + SchemeNetworkClient, +} from "../../../src/types/mechanisms"; import { PaymentPayload, PaymentRequirements } from "../../../src/types/payments"; /** @@ -13,6 +17,7 @@ export class MockSchemeNetworkClient implements SchemeNetworkClient { public createPaymentPayloadCalls: Array<{ x402Version: number; requirements: PaymentRequirements; + context?: PaymentPayloadContext; }> = []; /** @@ -37,12 +42,18 @@ export class MockSchemeNetworkClient implements SchemeNetworkClient { * * @param x402Version * @param paymentRequirements + * @param context */ async createPaymentPayload( x402Version: number, paymentRequirements: PaymentRequirements, + context?: PaymentPayloadContext, ): Promise> { - this.createPaymentPayloadCalls.push({ x402Version, requirements: paymentRequirements }); + this.createPaymentPayloadCalls.push({ + x402Version, + requirements: paymentRequirements, + ...(context ? { context } : {}), + }); if (this.payloadResult instanceof Error) { throw this.payloadResult; diff --git a/typescript/packages/core/test/unit/client/x402Client.test.ts b/typescript/packages/core/test/unit/client/x402Client.test.ts index 23546588ef..d73005084c 100644 --- a/typescript/packages/core/test/unit/client/x402Client.test.ts +++ b/typescript/packages/core/test/unit/client/x402Client.test.ts @@ -420,6 +420,10 @@ describe("x402Client", () => { expect(mockClient.createPaymentPayloadCalls.length).toBe(1); expect(mockClient.createPaymentPayloadCalls[0].x402Version).toBe(2); + expect(mockClient.createPaymentPayloadCalls[0].context).toEqual({ + extensions: paymentRequired.extensions, + resource: paymentRequired.resource, + }); }); }); diff --git a/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts b/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts index ad9d8d6a41..4c13816c7d 100644 --- a/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts +++ b/typescript/packages/core/test/unit/http/x402HTTPResourceService.test.ts @@ -849,6 +849,11 @@ describe("x402HTTPResourceServer", () => { // Create payment payload with matching requirements const payload = buildPaymentPayload({ accepted: matchingRequirements, + resource: { + url: "https://example.com/api/test", + description: "", + mimeType: "", + }, }); // Use proper encoding for payment header @@ -917,7 +922,14 @@ describe("x402HTTPResourceServer", () => { maxTimeoutSeconds: 300, extra: {}, }); - const payload = buildPaymentPayload({ accepted: matchingRequirements }); + const payload = buildPaymentPayload({ + accepted: matchingRequirements, + resource: { + url: "https://example.com/api/test", + description: "", + mimeType: "", + }, + }); const { decodePaymentRequiredHeader, encodePaymentSignatureHeader } = await import( "../../../src/http" ); @@ -941,6 +953,57 @@ describe("x402HTTPResourceServer", () => { } }); + it("rejects a client-carried resource that differs from the protected route", async () => { + Object.assign(mockScheme, { requireMatchingPayloadResource: true }); + const routes = { + "/api/test": { + accepts: { + scheme: "exact", + payTo: "0xabc", + price: "$1.00" as Price, + network: "eip155:8453" as Network, + }, + }, + }; + const requirements = buildPaymentRequirements({ + scheme: "exact", + network: "eip155:8453" as Network, + payTo: "0xabc", + amount: "1000000", + asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + maxTimeoutSeconds: 300, + extra: {}, + }); + const payload = buildPaymentPayload({ + accepted: requirements, + resource: { + url: "https://example.com/api/other", + description: "", + mimeType: "", + }, + }); + const { decodePaymentRequiredHeader, encodePaymentSignatureHeader } = await import( + "../../../src/http" + ); + const adapter = new MockHTTPAdapter({ + "payment-signature": encodePaymentSignatureHeader(payload), + }); + + const result = await new x402HTTPResourceServer(ResourceServer, routes).processHTTPRequest({ + adapter, + path: "/api/test", + method: "GET", + }); + + expect(mockFacilitator.verifyCalls).toHaveLength(0); + expect(result.type).toBe("payment-error"); + if (result.type === "payment-error") { + const response = decodePaymentRequiredHeader(result.response.headers["PAYMENT-REQUIRED"]); + expect(response.error).toBe("Payment resource does not match the protected resource"); + expect(response.resource.url).toBe("https://example.com/api/test"); + } + }); + it("should delegate verification to resource service", async () => { const routes = { "/api/test": { @@ -1330,13 +1393,17 @@ describe("x402HTTPResourceServer", () => { ResourceServer.onAfterVerify(async () => ({ skipHandler: true, response: { - contentType: "application/json", - body: { message: "Refund acknowledged" }, + status: 202, + contentType: "text/plain", + headers: { "X-Replayed-Result": "true" }, + body: Buffer.from("Refund acknowledged"), + isRaw: true, }, })); const routes = { "/api/refund": { + resource: "https://example.com/api/refund", accepts: { scheme: "exact", payTo: "0xabc", @@ -1357,7 +1424,14 @@ describe("x402HTTPResourceServer", () => { maxTimeoutSeconds: 300, extra: {}, }); - const payload = buildPaymentPayload({ accepted: matchingRequirements }); + const payload = buildPaymentPayload({ + accepted: matchingRequirements, + resource: { + url: "https://example.com/api/refund", + description: "", + mimeType: "", + }, + }); const { encodePaymentSignatureHeader } = await import("../../../src/http"); const paymentHeader = encodePaymentSignatureHeader(payload); @@ -1375,10 +1449,12 @@ describe("x402HTTPResourceServer", () => { expect(result.type).toBe("payment-error"); if (result.type === "payment-error") { - expect(result.response.status).toBe(200); + expect(result.response.status).toBe(202); expect(result.response.headers["PAYMENT-RESPONSE"]).toBeDefined(); + expect(result.response.headers["X-Replayed-Result"]).toBe("true"); expect(result.response.headers["Cache-Control"]).toBe("private"); - expect(result.response.body).toEqual({ message: "Refund acknowledged" }); + expect(result.response.body).toEqual(Buffer.from("Refund acknowledged")); + expect(result.response.isRaw).toBe(true); } }); diff --git a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts index e37a871cda..1d90a3e370 100644 --- a/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts +++ b/typescript/packages/core/test/unit/server/x402ResourceServer.test.ts @@ -803,6 +803,7 @@ describe("x402ResourceServer", () => { abort: true, reason: "reservation_lost", message: "channel busy", + status: 409, })) .onAfterVerify(laterHook) .onVerifiedPaymentCanceled(cancellationHook); @@ -815,6 +816,7 @@ describe("x402ResourceServer", () => { expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("reservation_lost"); expect(result.invalidMessage).toBe("channel busy"); + expect(result.httpStatus).toBe(409); expect(result.skipHandler).toBeUndefined(); expect(laterHook).not.toHaveBeenCalled(); expect(cancellationHook).toHaveBeenCalledTimes(1); @@ -2217,6 +2219,7 @@ describe("x402ResourceServer", () => { const paymentPayload = buildPaymentPayload(); const enrich = vi.fn(async ctx => { expect(ctx.paymentPayload).toBe(paymentPayload); + expect(ctx.requirement).toBe(ctx.requirements[0]); ctx.requirements[0].extra.ChannelState = { channelId: "0x123" }; }); scheme.enrichPaymentRequiredResponse = enrich; diff --git a/typescript/packages/http/express/src/index.ts b/typescript/packages/http/express/src/index.ts index 5f4a7167d8..2f9905bcfd 100644 --- a/typescript/packages/http/express/src/index.ts +++ b/typescript/packages/http/express/src/index.ts @@ -198,7 +198,7 @@ export function paymentMiddlewareFromHTTPServer( Object.entries(response.headers).forEach(([key, value]) => { res.setHeader(key, value); }); - if (response.isHtml) { + if (response.isHtml || response.isRaw) { res.send(response.body); } else { res.json(response.body || {}); @@ -328,7 +328,7 @@ export function paymentMiddlewareFromHTTPServer( paymentPayload, paymentRequirements, declaredExtensions, - { request: context, responseBody, responseHeaders }, + { request: context, responseBody, responseHeaders, responseStatus: res.statusCode }, ); // If settlement fails, return an error and do not send the buffered response diff --git a/typescript/packages/http/fastify/src/index.ts b/typescript/packages/http/fastify/src/index.ts index 31e3c1f7d0..bc1685b769 100644 --- a/typescript/packages/http/fastify/src/index.ts +++ b/typescript/packages/http/fastify/src/index.ts @@ -450,7 +450,12 @@ export function paymentMiddlewareFromHTTPServer( x402Context.paymentPayload, x402Context.paymentRequirements, x402Context.declaredExtensions, - { request: x402Context.requestContext, responseBody, responseHeaders }, + { + request: x402Context.requestContext, + responseBody, + responseHeaders, + responseStatus: reply.statusCode, + }, ); if (!settleResult.success) { diff --git a/typescript/packages/http/hono/src/adapter.test.ts b/typescript/packages/http/hono/src/adapter.test.ts index d1e837415a..7d12d38930 100644 --- a/typescript/packages/http/hono/src/adapter.test.ts +++ b/typescript/packages/http/hono/src/adapter.test.ts @@ -25,20 +25,26 @@ function createMockContext( const url = new URL(options.url || "https://example.com/api/test"); const headers = options.headers || {}; const query = options.query || {}; + const method = options.method || "GET"; + const raw = new Request(url, { + method, + headers, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + }); const mockContext = { req: { header: vi.fn((name: string) => headers[name]), - method: options.method || "GET", + method, path: url.pathname, url: url.toString(), + raw, query: vi.fn((name?: string) => { if (name === undefined) { return query; } return query[name]; }), - json: vi.fn().mockResolvedValue(options.body), }, } as unknown as Context; @@ -143,7 +149,7 @@ describe("HonoAdapter", () => { describe("getBody", () => { it("returns parsed JSON body", async () => { const body = { data: "test" }; - const c = createMockContext({ body }); + const c = createMockContext({ method: "POST", body }); const adapter = new HonoAdapter(c); expect(await adapter.getBody()).toEqual(body); }); @@ -151,11 +157,25 @@ describe("HonoAdapter", () => { it("returns undefined when body parsing fails", async () => { const mockContext = { req: { - json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + raw: { + clone: vi.fn(() => ({ + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + })), + }, }, } as unknown as Context; const adapter = new HonoAdapter(mockContext); expect(await adapter.getBody()).toBeUndefined(); }); }); + + describe("getRawBody", () => { + it("returns exact bytes without consuming the request", async () => { + const c = createMockContext({ method: "POST", body: { data: "test" } }); + const adapter = new HonoAdapter(c); + + expect(new TextDecoder().decode(await adapter.getRawBody())).toBe('{"data":"test"}'); + expect(await adapter.getBody()).toEqual({ data: "test" }); + }); + }); }); diff --git a/typescript/packages/http/hono/src/adapter.ts b/typescript/packages/http/hono/src/adapter.ts index cfa4a070cf..ffc7d4bb54 100644 --- a/typescript/packages/http/hono/src/adapter.ts +++ b/typescript/packages/http/hono/src/adapter.ts @@ -100,9 +100,18 @@ export class HonoAdapter implements HTTPAdapter { */ async getBody(): Promise { try { - return await this.c.req.json(); + return await this.c.req.raw.clone().json(); } catch { return undefined; } } + + /** + * Gets exact request-body bytes without consuming Hono's request stream. + * + * @returns Promise resolving to exact request-body bytes + */ + async getRawBody(): Promise { + return new Uint8Array(await this.c.req.raw.clone().arrayBuffer()); + } } diff --git a/typescript/packages/http/hono/src/index.ts b/typescript/packages/http/hono/src/index.ts index b688155d10..f3bd03b7f6 100644 --- a/typescript/packages/http/hono/src/index.ts +++ b/typescript/packages/http/hono/src/index.ts @@ -198,6 +198,8 @@ export function paymentMiddlewareFromHTTPServer( }); if (response.isHtml) { return c.html(response.body as string, response.status as 402); + } else if (response.isRaw) { + return c.body(response.body as never, response.status as 200); } else { return c.json(response.body || {}, response.status as 402); } @@ -247,7 +249,7 @@ export function paymentMiddlewareFromHTTPServer( paymentPayload, paymentRequirements, declaredExtensions, - { request: context, responseBody, responseHeaders }, + { request: context, responseBody, responseHeaders, responseStatus: res.status }, ); if (!settleResult.success) { diff --git a/typescript/packages/http/next/src/adapter.test.ts b/typescript/packages/http/next/src/adapter.test.ts index 1e13e88f62..59c0cfd50f 100644 --- a/typescript/packages/http/next/src/adapter.test.ts +++ b/typescript/packages/http/next/src/adapter.test.ts @@ -151,4 +151,18 @@ describe("NextAdapter", () => { expect(await adapter.getBody()).toBeUndefined(); }); }); + + describe("getRawBody", () => { + it("returns exact bytes without consuming the request", async () => { + const req = new NextRequest("https://example.com/api", { + method: "POST", + body: '{ "data": "test" }', + headers: { "Content-Type": "application/json" }, + }); + const adapter = new NextAdapter(req); + + expect(new TextDecoder().decode(await adapter.getRawBody())).toBe('{ "data": "test" }'); + expect(await adapter.getBody()).toEqual({ data: "test" }); + }); + }); }); diff --git a/typescript/packages/http/next/src/adapter.ts b/typescript/packages/http/next/src/adapter.ts index 180ac1efc5..97f7cf670a 100644 --- a/typescript/packages/http/next/src/adapter.ts +++ b/typescript/packages/http/next/src/adapter.ts @@ -109,9 +109,18 @@ export class NextAdapter implements HTTPAdapter { */ async getBody(): Promise { try { - return await this.req.json(); + return await this.req.clone().json(); } catch { return undefined; } } + + /** + * Gets exact request-body bytes without consuming the Next.js request stream. + * + * @returns Promise resolving to exact request-body bytes + */ + async getRawBody(): Promise { + return new Uint8Array(await this.req.clone().arrayBuffer()); + } } diff --git a/typescript/packages/http/next/src/utils.ts b/typescript/packages/http/next/src/utils.ts index b5965c2e38..cb497aaa32 100644 --- a/typescript/packages/http/next/src/utils.ts +++ b/typescript/packages/http/next/src/utils.ts @@ -145,6 +145,12 @@ export function handlePaymentError(response: HTTPResponseInstructions): NextResp headers, }); } + if (response.isRaw) { + return new NextResponse(response.body as BodyInit, { + status: response.status, + headers, + }); + } headers.set("Content-Type", "application/json"); return new NextResponse(JSON.stringify(response.body || {}), { status: response.status, @@ -196,7 +202,7 @@ export async function handleSettlement( paymentPayload, paymentRequirements, declaredExtensions, - { request: httpContext, responseBody, responseHeaders }, + { request: httpContext, responseBody, responseHeaders, responseStatus: response.status }, ); if (!result.success) { diff --git a/typescript/packages/mechanisms/cardano/README.md b/typescript/packages/mechanisms/cardano/README.md index 8c305bde5b..0c5151edc8 100644 --- a/typescript/packages/mechanisms/cardano/README.md +++ b/typescript/packages/mechanisms/cardano/README.md @@ -47,12 +47,21 @@ const provider = { blockfrost: { baseUrl: process.env.BLOCKFROST_PREPROD_URL!, p const clientSigner = toClientCardanoSigner({ mnemonic, network: "cardano:preprod", provider }); client.register("cardano:*", new ExactCardanoClient(clientSigner)); -// Facilitator (verify + settle). `awaitConfirmation` reports `confirmed` instead of `mempool`. -const facilitatorSigner = toFacilitatorCardanoSigner({ mnemonic, network: "cardano:preprod", provider, awaitConfirmation: true }); -facilitator.register("cardano:preprod", new ExactCardanoFacilitator(facilitatorSigner)); +// Facilitator (verify + settle). Supply a complete ledger phase-1 validator +// for the default server-submission mode and durable shared settlement state. +const facilitatorSigner = toFacilitatorCardanoSigner({ + network: "cardano:preprod", + provider, + awaitConfirmation: true, + validatePhase1Transaction: ledgerValidator.validatePhase1Transaction, +}); +facilitator.register( + "cardano:preprod", + new ExactCardanoFacilitator(facilitatorSigner, { settlementStore }), +); ``` -The facilitator only broadcasts the client's signed transaction, so its `mnemonic` is **optional** — omit it to run provider-only (no funds, no signer); when supplied it is used only to expose an address in the `/supported` response. The facilitator signer also implements the optional `evaluateTransaction` dry-run described below. A Koios provider (`{ koios: { baseUrl, token? } }`) may be used instead of Blockfrost. +The facilitator only broadcasts the client's signed transaction, so its `mnemonic` is **optional** — omit it to run provider-only (no funds, no signer); when supplied it is used only to expose an address in the `/supported` response. `settlementStore` must be an atomic durable `CardanoSettlementStore` shared by every facilitator worker. The reference signer also implements the optional `evaluateTransaction` script dry-run. A Koios provider (`{ koios: { baseUrl, token? } }`) may be used instead of Blockfrost. `provider.requestTimeoutMs` bounds every reference-signer provider query, build, submission, evaluation and confirmation wait; it defaults to 10 seconds. ## Testnet funds @@ -68,17 +77,53 @@ from the faucet; preprod **USDM** must be sourced separately, so use lovelace fo Per spec, three methods can be selected via `requirements.extra.assetTransferMethod`: - `default` — address-to-address payments. No extra verification beyond the core rules. -- `masumi` — locks funds into Masumi's `vested_pay` escrow for **concrete agent-to-agent payments**. The base facilitator builds and verifies the fixed 19-field lock datum (`buildMasumiLockInline` / `verifyMasumiLock`); no subclassing is required. +- `masumi` — locks funds into Masumi's `vested_pay` escrow for **concrete agent-to-agent payments**. Issue the 402 with `issueMasumiRequirements` (it derives `payTo` from the deployment parameters, builds the request commitment and gets the seller's CIP-8 signature over `termsDigest`); the client and facilitator both re-verify that authorization, and the facilitator additionally checks the 19-field lock datum (`verifyMasumiLock`). No subclassing is required. - `script` — locks funds into **any contract defined by the server**, with an optional arbitrary datum. The base facilitator reconstructs the script address from `extra.script`/`parameters` (or `scriptHash`) and verifies it equals `requirements.payTo`. Supply `extra.datum` (CBOR hex) to attach an inline datum for contracts that require one — the client attaches it verbatim; because the datum is arbitrary and contract-specific, the facilitator does **not** verify its contents, so a correct datum is the server's responsibility (a wrong or missing one strands the funds). Use this to lock into your own contract; use `masumi` for agent payments. Overriding `runMethodSpecificChecks` is **not** required for any built-in method; if you subclass to add a custom method, call `super.runMethodSpecificChecks(...)` so the Masumi and script checks still run. +## Submission and confirmation policy + +`requirements.extra.submissionPolicy` selects who broadcasts: `server` (the default when absent), `client`, or `either`. The paid payload echoes the normalized `submissionMode`, which must be allowed by the policy and must stay the same across retries for one transaction. Server mode requires a complete ledger phase-1 validator through `validatePhase1Transaction`; script evaluation alone is not enough. Client mode requires `getTransactionEvidence`, because the client broadcasts before the paid retry and the facilitator must authenticate that exact transaction. `/supported` advertises only modes for which these hooks exist. + +`requirements.extra.confirmationPolicy.l1Confirmations` sets the evidence required before `settle()` reports success: `-1` authenticated mempool acceptance, `0` canonical block inclusion, `1..20` that many newer blocks. It defaults to `1`. Below the threshold, `settle()` returns `errorReason: "payment_pending"` with the strongest evidence in `extra`; the paid retry resumes observing the same transaction without resubmitting it. + +Hydra settlement is **not implemented**: a `settlementLayer: "hydra"` payload is rejected and `/supported` advertises L1 only. Authenticating a Hydra payment needs verified Init state, head parameters, a seller-participant binding and `SnapshotConfirmed` evidence. + +## Idempotency boundary + +`settle()` is idempotent per canonical transaction ID, not one-shot. The spec requires a paid retry to repeat the exact original `PAYMENT-SIGNATURE` and the verifier to resume observing the same transaction, so a terminal "already settled" state would strand any payment that needs more confirmations than a single call can wait for. What this package guarantees is that a given transaction is **broadcast at most once** and always reports the same ledger truth. + +A definitive pre-ledger rejection is terminal for that issued payment. The facilitator retains both the transaction and Masumi `termsDigest` tombstones, and the resource server marks the protected operation for manual reconciliation. It does not accept corrected transaction bytes after the handler has run: doing so could bind one handler result to a different payment. Ambiguous transport or node failures remain non-releasable for the same reason. + +Binding a settled payment to a **single protected operation** is the resource server's responsibility, which the spec assigns explicitly: key the record by canonical transaction ID for `default` and `script`. For `masumi` the binding is stronger and already enforced here — `termsDigest` covers exactly one issued 402, so a payment cannot be reused against a second one (each carries a fresh `sellerNonce`). + +Replay state must survive restarts. Resource servers must supply an atomic durable `CardanoOperationStore`; facilitators must supply an atomic durable `CardanoSettlementStore`. The process-local stores are available only through explicit configuration for tests and disposable development. They stop at their configured entry limit and must not be used in production. + +Each Cardano 402 includes an opaque `cardanoReplayProtection` challenge bound to the request fingerprint and the selected requirements. The normal x402 v2 paid retry echoes this top-level extension. The first canonical transaction that uses the challenge consumes it; the same challenge can then retry only that transaction. Anonymous server-submission retries therefore remain bound to their original 402. + +Client submission also requires a validated `requestBinding`. Its transaction is public before the paid request reaches the resource server, so an opaque HTTP challenge alone cannot stop a mempool observer from racing the request with another valid challenge. Run authentication before the x402 middleware and return the validated principal or tenant from `requestBinding`. Authorization, cookie and API-key headers remain part of the request fingerprint, but arbitrary non-empty header values do not prove authentication. Cached responses omit authentication and cookie headers. Production `CardanoOperationStore` implementations must persist challenge issuance and consumption atomically with operation claims. + +## Relationship to `masumi-payment-service` + +The `masumi` method locks into the **real** deployed `vested_pay` V2 escrow: the compiled validator is taken verbatim from `masumi-payment-service`, and `payTo` is re-derived from the deployment parameters, so the canonical addresses match Masumi's own `PAYMENT_SMART_CONTRACT_ADDRESS_V2_*` exactly. The 19-field datum, the `collateral_return_lovelace` floor, the post-`SubmitResult` min-UTxO headroom, the deadline minimums and the `blockchainIdentifier` encoding all follow Masumi's rules, so a lock issued here is locatable and structurally valid on chain. + +The **seller authorization does not**. `reference_signature` here is a CIP-8 signature over this scheme's `termsDigest` (`SHA-256("masumi:x402:terms:v1\n" || JCS(signedTerms))`). `masumi-payment-service` verifies the same datum field against a signature over `SHA-256(stableStringify(signedBlockchainIdentifierPayload))` — a different payload entirely. The divergence is deliberate: `termsDigest` covers the exact issued 402 and is what binds a payment to one protected operation, which is the whole basis of this package's replay and idempotency guarantees. Signing Masumi's payload instead would break that binding. + +The consequence is concrete and worth stating plainly: **a lock created by this package cannot be driven through a `masumi-payment-service` node.** Masumi tooling can decode the `blockchainIdentifier` and find the UTxO, but its purchase-init check will reject the signature, so result submission, refunds and dispute resolution must be driven by x402-aware tooling holding the seller key. Use the `masumi` method when you want the escrow's guarantees inside an x402 flow — not as a transport into an existing Masumi deployment. + +Two smaller asymmetries follow from the same split. This package requires `lockedLovelace == requestedLovelace + collateral_return_lovelace` **exactly**, where Masumi tolerates lovelace overpayment; a Masumi-built transaction that rounds up to min-UTxO therefore will not satisfy an x402 402. And datum addresses are restricted to enterprise key addresses and base addresses whose payment *and* stake credentials are both key hashes — Masumi's `getPubKeyAddressDatum` accepts nothing else, and a script stake credential or pointer address would leave the escrow unspendable by its tooling. + +## Masumi registry claims + +A non-empty `terms.agentIdentifier` claims a Masumi V2 registry identity. The policy prefix alone proves nothing — anyone can copy a registered agent's identifier into their own terms — so such a claim is **rejected** unless you supply a `validateRegistryClaim` validator (on the facilitator config and, for the client, `validateMasumiRegistryClaim`) that independently checks the asset, seller authorization, metadata, endpoint, network and price on the selected network. Unregistered sellers (an absent, `null` or empty identifier) need no validator. + ## Settlement status -Cardano uses Ouroboros Praos (probabilistic finality). The default `settle()` returns whatever status the underlying signer reports. Granting access on `mempool` is **strongly discouraged** by the spec. +Cardano uses Ouroboros Praos (probabilistic finality). `settle()` reports the strongest verified evidence in `extra` (`status`, `confirmations`, `submissionMode`). Granting access on `mempool` is **strongly discouraged** by the spec, so the facilitator refuses a mempool-only result unless the operator sets `acceptMempool` *and* the policy allows `-1`. -## Optional cryptographic authorization check +## Script evaluation -The facilitator's structural checks (network, recipient, amount, asset, nonce, TTL, witness presence) are inexpensive but do not prove the supplied witnesses actually authorize the consumed inputs. To close that gap, implement the optional `evaluateTransaction(signedTransactionBase64, network)` method on your `FacilitatorCardanoSigner`; the facilitator will call it after the structural checks pass and treat any thrown error as a verification failure. Typical implementations route this to a Cardano node `evaluate-tx` endpoint or to Blockfrost's `/utils/txs/evaluate`. +Server submission requires `validatePhase1Transaction`, which must apply the complete Cardano phase-1 ledger rules to the exact signed transaction. The optional `evaluateTransaction(signedTransactionBase64, network)` hook is narrower: it dry-runs Plutus execution and does not prove value conservation or input authorization. Typical implementations route it to a Cardano node `evaluate-tx` endpoint or Blockfrost's `/utils/txs/evaluate`. See `specs/schemes/exact/scheme_exact_cardano.md` for the full protocol description. diff --git a/typescript/packages/mechanisms/cardano/package.json b/typescript/packages/mechanisms/cardano/package.json index 7c09ca96cf..023d64e698 100644 --- a/typescript/packages/mechanisms/cardano/package.json +++ b/typescript/packages/mechanisms/cardano/package.json @@ -44,7 +44,9 @@ }, "dependencies": { "@evolution-sdk/evolution": "^0.5.9", - "@x402/core": "workspace:~" + "@noble/hashes": "^2.2.0", + "@x402/core": "workspace:~", + "lz-string": "^1.5.0" }, "exports": { ".": { diff --git a/typescript/packages/mechanisms/cardano/src/constants.ts b/typescript/packages/mechanisms/cardano/src/constants.ts index 5b49d674e9..4b5da0a1c2 100644 --- a/typescript/packages/mechanisms/cardano/src/constants.ts +++ b/typescript/packages/mechanisms/cardano/src/constants.ts @@ -134,6 +134,10 @@ export const CARDANO_MIN_UTXO_OVERHEAD_BYTES = 160; * - assetName: 0..32 bytes -> 0..64 hex characters. */ export const CARDANO_ASSET_REGEX = /^(lovelace|[0-9a-fA-F]{56}\.[0-9a-fA-F]{0,64})$/; +/** Canonical Cardano asset unit: lowercase policy/asset hex, or `lovelace`. */ +export const CANONICAL_CARDANO_ASSET_REGEX = /^(lovelace|[0-9a-f]{56}\.[0-9a-f]{0,64})$/; +/** Positive canonical decimal integer with no leading zero. */ +export const POSITIVE_CANONICAL_AMOUNT_REGEX = /^[1-9][0-9]*$/; /** * Cardano payment address regex (very permissive). @@ -149,6 +153,32 @@ export const CARDANO_ADDRESS_REGEX = /^(addr1|addr_test1)[0-9a-z]+$/; */ export const CARDANO_UTXO_REF_REGEX = /^[0-9a-fA-F]{64}#\d+$/; +/** + * Submission policy values a server may declare in `extra.submissionPolicy`. + * Omission normalizes to `server`. `either` is a policy, never a payload mode. + */ +export const SUBMISSION_POLICY_SERVER = "server"; +/** Submission policy: the client broadcasts before the paid retry. */ +export const SUBMISSION_POLICY_CLIENT = "client"; +/** Submission policy: the client picks either mode. */ +export const SUBMISSION_POLICY_EITHER = "either"; + +/** + * Default `confirmationPolicy.l1Confirmations` when the requirements omit it, + * and the inclusive bounds the spec allows (`-1` = authenticated mempool + * acceptance, `0` = canonical block inclusion, `1..20` = newer canonical blocks). + */ +export const DEFAULT_L1_CONFIRMATIONS = 1; +/** Lowest accepted `l1Confirmations` (authenticated mempool acceptance). */ +export const MIN_L1_CONFIRMATIONS = -1; +/** Highest accepted `l1Confirmations`. */ +export const MAX_L1_CONFIRMATIONS = 20; + +/** Settlement layer selected by a Masumi payload. */ +export const SETTLEMENT_LAYER_L1 = "l1"; +/** Settlement layer selected by a Masumi payload for a Hydra head. */ +export const SETTLEMENT_LAYER_HYDRA = "hydra"; + /** * Maximum allowed value for assetTransferMethod. */ @@ -220,6 +250,8 @@ export function getDefaultUsdmAsset(network: string): string { export const ERR_UNSUPPORTED_SCHEME = "unsupported_scheme"; /** Error: payload is missing required fields. */ export const ERR_INVALID_PAYLOAD = "invalid_exact_cardano_payload"; +/** Error: canonical payment requirements are malformed. */ +export const ERR_REQUIREMENTS_INVALID = "invalid_exact_cardano_requirements"; /** Error: declared and accepted networks differ. */ export const ERR_NETWORK_MISMATCH = "network_mismatch"; /** Error: signed transaction could not be CBOR decoded. */ @@ -249,16 +281,29 @@ export const ERR_VALIDITY_NOT_YET_VALID = "invalid_exact_cardano_payload_not_yet export const ERR_CHAIN_LOOKUP_FAILED = "exact_cardano_facilitator_chain_lookup_failed"; /** Error: settlement failed when submitting the transaction. */ export const ERR_SETTLEMENT_FAILED = "exact_cardano_settlement_failed"; +/** Error: the node definitively rejected the transaction before ledger acceptance. */ +export const ERR_SETTLEMENT_DEFINITIVELY_REJECTED = + "exact_cardano_settlement_definitively_rejected"; /** Error: facilitator declined a `mempool`-only settlement and `acceptMempool` is disabled. */ export const ERR_SETTLEMENT_NOT_CONFIRMED = "exact_cardano_settlement_not_confirmed"; /** Error: duplicate settlement detected within the cache window. */ export const ERR_DUPLICATE_SETTLEMENT = "duplicate_settlement"; +/** Error: the transaction is valid but has not yet reached the required evidence level. */ +export const ERR_PAYMENT_PENDING = "payment_pending"; /** Error: the script assetTransferMethod was selected but reconstruction failed. */ export const ERR_SCRIPT_ADDRESS_MISMATCH = "invalid_exact_cardano_payload_script_address_mismatch"; /** Error: transaction is not signed (no vkey/bootstrap witnesses present). */ export const ERR_TRANSACTION_UNSIGNED = "invalid_exact_cardano_payload_unsigned"; /** Error: a vkey witness signature is not valid over the transaction body. */ export const ERR_INVALID_SIGNATURE = "invalid_exact_cardano_payload_invalid_signature"; +/** + * Error: the transaction is a failed-script (phase-2 invalid) transaction. It + * lands under its own id but consumes collateral instead of its inputs and + * creates none of its declared outputs, so it pays nothing. + */ +export const ERR_TRANSACTION_PHASE2_INVALID = "invalid_exact_cardano_payload_phase2_invalid"; +/** Error: transaction fails complete Cardano ledger phase-1 validation. */ +export const ERR_TRANSACTION_PHASE1_INVALID = "invalid_exact_cardano_payload_phase1_invalid"; /** Error: the recipient output's lovelace is below the protocol min-UTXO. */ export const ERR_MIN_UTXO_INSUFFICIENT = "invalid_exact_cardano_payload_min_utxo_insufficient"; /** Error: masumi payTo is not the known Masumi escrow address for the network. */ @@ -280,3 +325,37 @@ export const ERR_MASUMI_MIN_UTXO = "invalid_exact_cardano_payload_masumi_min_utx export const ERR_MASUMI_REFERENCE_SCRIPT = "invalid_exact_cardano_payload_masumi_reference_script"; /** Error: the escrow output does not carry the requested asset/amount. */ export const ERR_MASUMI_ASSET = "invalid_exact_cardano_payload_masumi_asset"; +/** Error: `extra.submissionPolicy` / `extra.confirmationPolicy` is malformed. */ +export const ERR_POLICY_INVALID = "invalid_exact_cardano_requirements_policy"; +/** Error: the normalized `payload.submissionMode` is not allowed by `submissionPolicy`. */ +export const ERR_SUBMISSION_MODE_MISMATCH = + "invalid_exact_cardano_payload_submission_mode_mismatch"; +/** Error: the TTL is later than now + `maxTimeoutSeconds` (rule 7 upper bound). */ +export const ERR_TTL_TOO_FAR = "invalid_exact_cardano_payload_ttl_too_far"; +/** Error: client mode requires authenticated evidence the facilitator cannot obtain. */ +export const ERR_EVIDENCE_UNAVAILABLE = "exact_cardano_facilitator_evidence_unavailable"; +/** Error: client-mode evidence does not prove the exact transaction consumed the nonce. */ +export const ERR_EVIDENCE_MISMATCH = "invalid_exact_cardano_payload_evidence_mismatch"; +/** Error: the payload selected a settlement layer this facilitator does not support. */ +export const ERR_SETTLEMENT_LAYER_UNSUPPORTED = + "invalid_exact_cardano_payload_settlement_layer_unsupported"; +/** Error: `payload.settlementLayer` is not allowed by `terms.settlementPolicy`. */ +export const ERR_SETTLEMENT_LAYER_MISMATCH = + "invalid_exact_cardano_payload_settlement_layer_mismatch"; +/** Error: the masumi `extra` block violates the closed-object wire schema. */ +export const ERR_MASUMI_SCHEMA = "invalid_exact_cardano_requirements_masumi_schema"; +/** Error: an `inputCommitment` part digest or the commitment digest does not recompute. */ +export const ERR_MASUMI_COMMITMENT = "invalid_exact_cardano_requirements_masumi_commitment"; +/** Error: the seller's COSE authorization over `termsDigest` does not verify. */ +export const ERR_MASUMI_SELLER_SIGNATURE = + "invalid_exact_cardano_requirements_masumi_seller_signature"; +/** Error: `blockchainIdentifier` does not decode to the reconstructed identifier. */ +export const ERR_MASUMI_IDENTIFIER = "invalid_exact_cardano_requirements_masumi_identifier"; +/** Error: `agentIdentifier` does not carry the Masumi V2 registry policy id. */ +export const ERR_MASUMI_AGENT_IDENTIFIER = + "invalid_exact_cardano_requirements_masumi_agent_identifier"; +/** Error: the derived deployment escrow address does not equal `payTo`. */ +export const ERR_MASUMI_DEPLOYMENT = "invalid_exact_cardano_requirements_masumi_deployment"; +/** Error: the transaction carries more than one output at the escrow address. */ +export const ERR_MASUMI_ESCROW_OUTPUT_COUNT = + "invalid_exact_cardano_payload_masumi_escrow_output_count"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts index 0d9f08c3b2..e05291a80e 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/client/scheme.ts @@ -1,17 +1,21 @@ import type { + PaymentPayloadContext, PaymentPayloadResult, PaymentRequirements, SchemeNetworkClient, } from "@x402/core/types"; import { CARDANO_ADDRESS_REGEX, - CARDANO_ASSET_REGEX, + CANONICAL_CARDANO_ASSET_REGEX, CARDANO_UTXO_REF_REGEX, isCardanoNetwork, SCHEME_EXACT, + POSITIVE_CANONICAL_AMOUNT_REGEX, + SUBMISSION_POLICY_EITHER, } from "../../constants"; +import { resolveCardanoPolicies } from "../../policy"; import type { ClientCardanoSigner } from "../../signer"; -import type { ExactCardanoPayload } from "../../types"; +import type { CardanoSubmissionMode, ExactCardanoPayload } from "../../types"; /** * Cardano client implementation for the Exact payment scheme. @@ -27,8 +31,14 @@ export class ExactCardanoScheme implements SchemeNetworkClient { * Creates a new Cardano client scheme. * * @param signer - The Cardano client signer. + * @param preferredSubmissionMode - Which mode to pick when the server's + * `submissionPolicy` is `either`. Defaults to `server`, matching the + * normalization of an absent policy. */ - constructor(private readonly signer: ClientCardanoSigner) {} + constructor( + private readonly signer: ClientCardanoSigner, + private readonly preferredSubmissionMode: CardanoSubmissionMode = "server", + ) {} /** * Builds a Cardano payment payload by delegating signing to the configured @@ -37,11 +47,13 @@ export class ExactCardanoScheme implements SchemeNetworkClient { * * @param x402Version - The x402 protocol version. * @param paymentRequirements - The payment requirements to fulfill. + * @param context - Payment-required context, including the protected resource. * @returns A promise resolving to the Cardano payment payload. */ async createPaymentPayload( x402Version: number, paymentRequirements: PaymentRequirements, + context?: PaymentPayloadContext, ): Promise { if (!isCardanoNetwork(paymentRequirements.network)) { throw new Error(`Unsupported Cardano network: ${paymentRequirements.network}`); @@ -55,16 +67,33 @@ export class ExactCardanoScheme implements SchemeNetworkClient { if (!paymentRequirements.asset) { throw new Error("Asset is required"); } - if (!CARDANO_ASSET_REGEX.test(paymentRequirements.asset)) { - throw new Error(`Invalid Cardano asset unit: ${paymentRequirements.asset}`); + if (!CANONICAL_CARDANO_ASSET_REGEX.test(paymentRequirements.asset)) { + throw new Error( + `Cardano asset must use canonical lowercase form: ${paymentRequirements.asset}`, + ); } if (!paymentRequirements.amount) { throw new Error("Amount is required"); } - if (!/^[0-9]+$/.test(paymentRequirements.amount)) { - throw new Error(`Amount must be a non-negative integer, got: ${paymentRequirements.amount}`); + if (!POSITIVE_CANONICAL_AMOUNT_REGEX.test(paymentRequirements.amount)) { + throw new Error( + `Amount must be a positive canonical integer, got: ${paymentRequirements.amount}`, + ); } + // The server's policy selects the submitter; `either` leaves the choice to + // the client. A client MUST NOT infer the policy from `/supported`. + const policies = resolveCardanoPolicies(paymentRequirements.extra); + if (!policies) { + throw new Error( + "Cardano payment requirements carry an invalid submission/confirmation policy", + ); + } + const submissionMode: CardanoSubmissionMode = + policies.submissionPolicy === SUBMISSION_POLICY_EITHER + ? this.preferredSubmissionMode + : policies.submissionPolicy; + const result = await this.signer.buildAndSignPaymentTransaction({ network: paymentRequirements.network, payTo: paymentRequirements.payTo, @@ -72,6 +101,8 @@ export class ExactCardanoScheme implements SchemeNetworkClient { amount: paymentRequirements.amount, maxTimeoutSeconds: paymentRequirements.maxTimeoutSeconds, extra: paymentRequirements.extra, + submissionMode, + ...(context?.resource ? { resource: context.resource } : {}), }); if (!result || typeof result.transaction !== "string" || result.transaction.length === 0) { @@ -80,10 +111,32 @@ export class ExactCardanoScheme implements SchemeNetworkClient { if (!result.nonce || !CARDANO_UTXO_REF_REGEX.test(result.nonce)) { throw new Error(`Cardano signer returned an invalid nonce: ${result.nonce}`); } + // A signer that ignored client mode would leave the transaction + // unbroadcast, and the facilitator — which must not submit it — would find + // no evidence for it. + if ( + (submissionMode === "client" && result.submissionMode !== "client") || + (result.submissionMode !== undefined && result.submissionMode !== submissionMode) + ) { + throw new Error( + `Cardano signer honoured submissionMode ${String(result.submissionMode)}, expected ${submissionMode}`, + ); + } + + const method = paymentRequirements.extra?.assetTransferMethod ?? "default"; + if ( + method !== "masumi" && + (result.settlementLayer !== undefined || result.headId !== undefined) + ) { + throw new Error("Cardano signer returned Masumi settlement fields for a non-Masumi payment"); + } const payload: ExactCardanoPayload = { transaction: result.transaction, nonce: result.nonce, + submissionMode, + ...(result.settlementLayer ? { settlementLayer: result.settlementLayer } : {}), + ...(result.headId ? { headId: result.headId } : {}), }; return { diff --git a/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts b/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts index 7399aaa69a..75a843b85c 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/facilitator/index.ts @@ -1,2 +1,4 @@ export { ExactCardanoScheme, supportedCardanoNetworks } from "./scheme"; export type { ExactCardanoFacilitatorConfig } from "./scheme"; +export { InMemoryCardanoSettlementStore } from "../../idempotency"; +export type { CardanoSettlementStore, CardanoSubmissionClaim } from "../../idempotency"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts index e6000d0979..7974960aa4 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scheme.ts @@ -1,19 +1,24 @@ import type { + Network, PaymentPayload, PaymentRequirements, SchemeNetworkFacilitator, SettleResponse, VerifyResponse, } from "@x402/core/types"; +import { randomBytes } from "node:crypto"; import { ASSET_TRANSFER_METHOD_DEFAULT, ASSET_TRANSFER_METHOD_MASUMI, ASSET_TRANSFER_METHOD_SCRIPT, CARDANO_NETWORKS, + CANONICAL_CARDANO_ASSET_REGEX, ERR_AMOUNT_INSUFFICIENT, ERR_ASSET_MISMATCH, ERR_CHAIN_LOOKUP_FAILED, ERR_DUPLICATE_SETTLEMENT, + ERR_EVIDENCE_MISMATCH, + ERR_EVIDENCE_UNAVAILABLE, ERR_INPUT_NOT_AVAILABLE, ERR_INVALID_PAYLOAD, ERR_INVALID_SIGNATURE, @@ -23,35 +28,67 @@ import { ERR_NONCE_INVALID, ERR_NONCE_NOT_IN_INPUTS, ERR_NONCE_NOT_ON_CHAIN, + ERR_PAYMENT_PENDING, + ERR_POLICY_INVALID, ERR_RECIPIENT_MISMATCH, + ERR_REQUIREMENTS_INVALID, ERR_SCRIPT_ADDRESS_MISMATCH, + ERR_SETTLEMENT_LAYER_MISMATCH, ERR_SETTLEMENT_FAILED, + ERR_SETTLEMENT_DEFINITIVELY_REJECTED, ERR_SETTLEMENT_NOT_CONFIRMED, + ERR_SUBMISSION_MODE_MISMATCH, ERR_TRANSACTION_DECODE_FAILED, + ERR_TRANSACTION_PHASE1_INVALID, + ERR_TRANSACTION_PHASE2_INVALID, ERR_TRANSACTION_UNSIGNED, ERR_TTL_EXPIRED, + ERR_TTL_TOO_FAR, ERR_UNSUPPORTED_SCHEME, ERR_VALIDITY_NOT_YET_VALID, getCardanoNetworkId, isCardanoNetwork, + MAX_L1_CONFIRMATIONS, + MIN_L1_CONFIRMATIONS, normalizeCardanoNetwork, + POSITIVE_CANONICAL_AMOUNT_REGEX, SCHEME_EXACT, } from "../../constants"; +import { MAX_CARDANO_INPUT_LOOKUP_CONCURRENCY, MAX_CARDANO_TRANSACTION_INPUTS } from "../../limits"; +import { + confirmationsSatisfy, + normalizeSubmissionMode, + resolveCardanoPolicies, + submissionModeAllowed, + type ResolvedCardanoPolicies, +} from "../../policy"; import type { CardanoExtra, - CardanoExtraMasumi, CardanoExtraScript, + CardanoSubmissionMode, DecodedCardanoTransaction, ExactCardanoPayload, } from "../../types"; -import type { CardanoUtxoSnapshot, FacilitatorCardanoSigner } from "../../signer"; +import type { + CardanoSettlementEvidence, + CardanoUtxoSnapshot, + FacilitatorCardanoSigner, +} from "../../signer"; import { decodeCardanoPayload, decodeCardanoTransaction, minUtxoLovelace, parseUtxoRef, + slotToPosixMs, } from "../../utils"; -import { verifyMasumiLock } from "../masumi/verify"; +import { InMemoryCardanoSettlementStore, type CardanoSettlementStore } from "../../idempotency"; +import { buildSignedTerms, computeTermsDigest } from "../masumi/digests"; +import { validateMasumiExtra } from "../masumi/schema"; +import { + verifyMasumiLock, + type MasumiDeploymentValidator, + type MasumiRegistryValidator, +} from "../masumi/verify"; import { scriptAddressMatches } from "./scriptAddress"; /** @@ -59,16 +96,49 @@ import { scriptAddressMatches } from "./scriptAddress"; */ export interface ExactCardanoFacilitatorConfig { /** - * Time-to-live (in milliseconds) for the duplicate-settlement cache. Defaults - * to two minutes which exceeds typical Cardano slot/block lifetimes. + * Atomic durable transaction and Masumi-terms claim store shared by every + * worker and deployment. Replay tombstones must survive process restarts. */ - duplicateCacheTtlMs?: number; + settlementStore?: CardanoSettlementStore; /** - * If `true` the facilitator returns `status: "mempool"` even when the signer - * confirms inclusion. Default is `false`. Even when enabled the spec - * strongly discourages granting access on mempool inclusion. + * Entry limit for an explicitly selected process-local settlement store. + * Supplying this opts into volatile replay state and is suitable only for + * tests and disposable development facilitators. + */ + inMemorySettlementStoreMaxEntries?: number; + /** + * If `true` the facilitator may settle on authenticated mempool evidence when + * the selected `confirmationPolicy` allows it (`l1Confirmations: -1`). Default + * is `false`: mempool inclusion can be rolled back, so the facilitator refuses + * it regardless of policy unless the operator opts in. */ acceptMempool?: boolean; + /** + * How long `settle()` waits for evidence to reach the selected + * `confirmationPolicy` before reporting `payment_pending`. Defaults to 90s. + */ + confirmationTimeoutMs?: number; + /** + * Interval between evidence polls while waiting for confirmations. + */ + confirmationPollMs?: number; + /** + * Independently validates a Masumi registry claim on the selected network. + * Without one, a non-empty `terms.agentIdentifier` is rejected rather than + * taken on trust; unregistered sellers are unaffected. + */ + validateRegistryClaim?: MasumiRegistryValidator; + /** Explicitly approves a non-canonical Masumi V2 deployment. */ + validateCustomMasumiDeployment?: MasumiDeploymentValidator; + /** + * Allows a client-submitted payment to run Plutus scripts. Default `false`: + * only a script-running transaction can land phase-2 invalid — creating none + * of the outputs it declares — and the `is_valid` flag that marks it is + * outside the transaction id, so a client can broadcast the failing form and + * present the passing one. Enable only with an evidence provider that + * verifies `valid_contract`. + */ + allowClientScriptExecution?: boolean; } /** @@ -91,24 +161,57 @@ function describeErrorChain(error: unknown, maxDepth = 5): string { return parts.join(" | "); } +/** + * Everything `verify()` resolved, so `settle()` does not redo the work. + */ +interface VerifiedPayment { + payload: ExactCardanoPayload; + decoded: DecodedCardanoTransaction; + policies: ResolvedCardanoPolicies; + mode: CardanoSubmissionMode; + payer: string; +} + /** * Cardano facilitator implementation for the Exact payment scheme. * - * Performs all checks listed in the "Facilitator Verification Rules" section - * of `specs/schemes/exact/scheme_exact_cardano.md` (rules 1-6) before - * accepting a payment. Settlement re-runs verification before submitting. + * Enforces the "Facilitator Verification Rules" of + * `specs/schemes/exact/scheme_exact_cardano.md` (rules 1-9) before accepting a + * payment, then settles according to the selected submission policy: in server + * mode it submits the transaction, in client mode it authenticates evidence for + * the transaction the client already broadcast and never submits it again. + * + * The duplicate-settlement cache is keyed by the **canonical Cardano transaction + * ID**, never by the serialized CBOR: witness sets and equally valid encodings + * differ without changing the ledger transaction, so an encoding-level key is + * trivially bypassed. Production deployments must configure an atomic durable + * shared {@link CardanoSettlementStore}; process-local storage is explicit and + * intended only for tests or disposable development. * - * The duplicate-settlement cache is in-process only; across multiple - * facilitator instances the authoritative replay guard is the on-chain UTXO - * spend (rule 5), which makes the consumed nonce UTXO fail re-verification. + * **Idempotency boundary.** `settle()` is deliberately idempotent per + * transaction id rather than one-shot: the spec requires a paid retry to repeat + * the exact original `PAYMENT-SIGNATURE` and the verifier to "resume observation + * of the same canonical transaction ID", which a terminal state would break — + * a payment that needed more confirmations than one call could wait for would + * become permanently unsettleable. What this facilitator guarantees is that one + * transaction is broadcast at most once and always reports the same ledger + * truth. Binding a settled transaction to a *single protected operation* is the + * resource server's job, which the spec assigns it explicitly: it keys its + * record by canonical transaction ID for `default` and `script`, and by + * `termsDigest` for `masumi` (already enforced here, so a Masumi payment cannot + * be reused across two 402s — each carries a fresh `sellerNonce`). */ export class ExactCardanoScheme implements SchemeNetworkFacilitator { readonly scheme = SCHEME_EXACT; readonly caipFamily = "cardano:*"; - private readonly settlementCache = new Map(); - private readonly duplicateCacheTtlMs: number; + private readonly settlementStore: CardanoSettlementStore; private readonly acceptMempool: boolean; + private readonly confirmationTimeoutMs: number; + private readonly confirmationPollMs: number; + private readonly validateRegistryClaim?: MasumiRegistryValidator; + private readonly validateCustomMasumiDeployment?: MasumiDeploymentValidator; + private readonly allowClientScriptExecution: boolean; /** * Creates a new Cardano facilitator scheme. @@ -120,21 +223,72 @@ export class ExactCardanoScheme implements SchemeNetworkFacilitator { private readonly signer: FacilitatorCardanoSigner, config: ExactCardanoFacilitatorConfig = {}, ) { - this.duplicateCacheTtlMs = config.duplicateCacheTtlMs ?? 120_000; + if (config.settlementStore) { + this.settlementStore = config.settlementStore; + } else if (config.inMemorySettlementStoreMaxEntries !== undefined) { + this.settlementStore = new InMemoryCardanoSettlementStore( + config.inMemorySettlementStoreMaxEntries, + ); + } else { + throw new Error( + "Cardano facilitators require a durable settlementStore; pass inMemorySettlementStoreMaxEntries explicitly only for tests or disposable development", + ); + } this.acceptMempool = config.acceptMempool ?? false; + this.confirmationTimeoutMs = config.confirmationTimeoutMs ?? 90_000; + this.confirmationPollMs = config.confirmationPollMs ?? 5_000; + this.validateRegistryClaim = config.validateRegistryClaim; + this.validateCustomMasumiDeployment = config.validateCustomMasumiDeployment; + this.allowClientScriptExecution = config.allowClientScriptExecution ?? false; } /** - * Returns extra metadata for the `/supported` endpoint. Cardano payments do - * not require server-side metadata in `default` mode, but consumers may - * extend this method in subclasses. + * Returns the capabilities advertised in the `/supported` response: the + * transfer methods, settlement layers and submission modes this facilitator + * can actually service, plus the L1 confirmation range per mode. + * + * `/supported` only describes capabilities — the selected policies always come + * from the 402 requirements. * * @param _network - The Cardano network identifier (unused). - * @returns Always `undefined` for the default Cardano scheme. + * @returns The advertised capability block. */ getExtra(_network: string): Record | undefined { void _network; - return undefined; + const supportsServerSubmission = typeof this.signer.validatePhase1Transaction === "function"; + const supportsClientSubmission = this.canAuthenticateEvidence(); + return { + assetTransferMethods: [ + ASSET_TRANSFER_METHOD_DEFAULT, + ASSET_TRANSFER_METHOD_MASUMI, + ASSET_TRANSFER_METHOD_SCRIPT, + ], + // Hydra needs head-authenticated evidence this facilitator cannot produce. + settlementLayers: ["l1"], + submissionModes: [ + ...(supportsServerSubmission ? ["server"] : []), + ...(supportsClientSubmission ? ["client"] : []), + ], + l1Confirmations: { + // Mempool-only evidence is refused unless the operator opted in. + ...(supportsServerSubmission + ? { + server: { + minimum: this.acceptMempool ? MIN_L1_CONFIRMATIONS : 0, + maximum: supportsClientSubmission ? MAX_L1_CONFIRMATIONS : 0, + }, + } + : {}), + ...(supportsClientSubmission + ? { + client: { + minimum: this.acceptMempool ? MIN_L1_CONFIRMATIONS : 0, + maximum: MAX_L1_CONFIRMATIONS, + }, + } + : {}), + }, + }; } /** @@ -150,8 +304,7 @@ export class ExactCardanoScheme implements SchemeNetworkFacilitator { } /** - * Verifies a Cardano payment against the supplied requirements following - * the spec's six rules. + * Verifies a Cardano payment against the supplied requirements. * * @param payload - The Cardano payment payload. * @param requirements - The payment requirements being fulfilled. @@ -161,42 +314,405 @@ export class ExactCardanoScheme implements SchemeNetworkFacilitator { payload: PaymentPayload, requirements: PaymentRequirements, ): Promise { + const result = await this.runVerification(payload, requirements); + return result.response; + } + + /** + * Settles a Cardano payment. + * + * In server mode the transaction is re-verified and submitted. In client mode + * it was already broadcast by the client, so the facilitator only + * authenticates evidence for that exact transaction and MUST NOT submit it + * again. Either way the response reports the strongest verified evidence, and + * `success` is `true` only once it meets `confirmationPolicy`. + * + * @param payload - The Cardano payment payload. + * @param requirements - The payment requirements. + * @returns A settle response describing success or failure. + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise { + // Dispatched through `this` so a subclass that tightens `verify()` also + // governs settlement. + const verifyResult = await this.verify(payload, requirements); + if (!verifyResult.isValid) { + return { + success: false, + errorReason: verifyResult.invalidReason ?? "verification_failed", + ...(verifyResult.invalidMessage ? { errorMessage: verifyResult.invalidMessage } : {}), + transaction: "", + network: payload.accepted.network, + }; + } + const state = this.resolvePaymentState(payload, requirements, verifyResult.payer ?? ""); + if (!state.ok) { + return { + success: false, + errorReason: state.reason, + ...(state.message ? { errorMessage: state.message } : {}), + transaction: "", + network: payload.accepted.network, + }; + } + const verified = state.verified; + + const { decoded, mode, policies } = verified; + const network = payload.accepted.network; + const required = policies.confirmationPolicy.l1Confirmations; + + // Claim the canonical transaction id, submission mode and optional Masumi + // terms digest in one atomic store operation. Splitting these writes can bind + // a quote without reserving its transaction when the store reaches capacity. + const ownerToken = randomBytes(16).toString("hex"); + const claim = await this.claimSettlement( + decoded.txHash, + mode, + ownerToken, + this.masumiTermsDigest(requirements), + ); + if (claim === "capacity-exceeded") { + return { + success: false, + errorReason: ERR_SETTLEMENT_FAILED, + errorMessage: "the Cardano settlement store is at capacity", + transaction: decoded.txHash, + network, + }; + } + if (claim === "mode-conflict") { + return { + success: false, + errorReason: ERR_SUBMISSION_MODE_MISMATCH, + errorMessage: "this transaction was already settled under the other submission mode", + transaction: decoded.txHash, + network, + }; + } + if (claim === "terms-conflict") { + return { + success: false, + errorReason: ERR_DUPLICATE_SETTLEMENT, + errorMessage: "termsDigest is already bound to another transaction", + transaction: decoded.txHash, + network, + }; + } + if (claim === "rejected") { + return { + success: false, + errorReason: ERR_SETTLEMENT_DEFINITIVELY_REJECTED, + errorMessage: "this transaction was definitively rejected before ledger acceptance", + transaction: decoded.txHash, + network, + }; + } + if (claim === "in-flight") { + return { + success: false, + errorReason: ERR_DUPLICATE_SETTLEMENT, + transaction: decoded.txHash, + network, + }; + } + + if (mode === "client") { + // The client already broadcast; the facilitator MUST NOT submit it again. + await this.markSubmitted(decoded.txHash, ownerToken); + const evidence = await this.awaitEvidence(decoded.txHash, network, required); + return this.evidenceResponse(evidence, decoded.txHash, network, mode, required, verified); + } + + let submissionStatus: "confirmed" | "mempool" | undefined; + if (claim === "fresh") { + try { + const submission = await this.signer.submitTransaction( + verified.payload.transaction, + requirements.network, + ); + if (submission.txHash.toLowerCase() !== decoded.txHash.toLowerCase()) { + throw new Error( + `submitter returned transaction ${submission.txHash}, expected ${decoded.txHash}`, + ); + } + submissionStatus = submission.status; + await this.markSubmitted(decoded.txHash, ownerToken); + } catch (cause) { + // Submission threw. A throw does NOT prove the transaction never + // reached the network: a signer that broadcasts and then waits for + // confirmation throws on a timeout with the transaction already in + // flight. Releasing the claim here would make the retry rebroadcast a + // transaction that may already have landed, so the spec requires a + // timeout, transport failure or unknown node result to RETAIN it. + // + // Ask the ledger before deciding. An `unknown` lookup is not proof that + // no submission occurred; only the signer's explicit definitive- + // rejection classifier may release the claim. + let landed = false; + if (this.canAuthenticateEvidence()) { + try { + const observed = await this.signer.getTransactionEvidence!( + decoded.txHash, + requirements.network, + ); + landed = observed.status !== "unknown"; + } catch { + // Cannot tell — keep the claim rather than risk a rebroadcast. + landed = true; + } + } + if (landed) { + // It is on the ledger despite the throw: record it as submitted so the + // retry resumes observing instead of submitting again. + await this.markSubmitted(decoded.txHash, ownerToken); + const evidence = await this.awaitEvidence(decoded.txHash, requirements.network, required); + return this.evidenceResponse(evidence, decoded.txHash, network, mode, required, verified); + } + const definitive = this.signer.isDefinitiveSubmissionRejection?.(cause) === true; + if (definitive) { + // The protected handler has already run by this point. Keep both the + // transaction and Masumi terms tombstones: accepting different bytes + // for the same result would risk binding that result to another + // payment, while releasing this transaction would rebroadcast bytes + // the node has already rejected definitively. + await this.markRejected(decoded.txHash, ownerToken); + } else { + // Unknown does not prove absence. Keep the canonical transaction ID + // claimed so a paid retry cannot rebroadcast a transaction that may + // still be valid and in flight. + await this.markSubmitted(decoded.txHash, ownerToken); + } + return { + success: false, + errorReason: definitive ? ERR_SETTLEMENT_DEFINITIVELY_REJECTED : ERR_SETTLEMENT_FAILED, + errorMessage: describeErrorChain(cause), + transaction: decoded.txHash, + network, + }; + } + } + // `claim === "submitted"` is the pending-confirmation retry: this exact + // transaction was already broadcast, so resume observing it instead of + // submitting it again. + + let evidence: CardanoSettlementEvidence; + if (this.canAuthenticateEvidence() || submissionStatus === undefined) { + evidence = await this.awaitEvidence(decoded.txHash, requirements.network, required); + // A transaction the node accepted may simply not be observable yet — most + // providers expose no mempool read. That is the pending-confirmation case, + // not evidence that the claimed transaction does not exist. + if (evidence.status === "unknown" && submissionStatus !== undefined) { + evidence = { status: "mempool", confirmations: MIN_L1_CONFIRMATIONS }; + } + } else { + // Without an evidence hook the submitter's own result is all we know: + // inclusion means canonical depth 0, otherwise mempool acceptance. + evidence = { + status: submissionStatus, + confirmations: submissionStatus === "confirmed" ? 0 : MIN_L1_CONFIRMATIONS, + }; + } + return this.evidenceResponse(evidence, decoded.txHash, network, mode, required, verified); + } + + /** + * Runs the verification step that depends on the assetTransferMethod + * declared in `requirements.extra`. + * + * - `default` / undefined: no extra verification beyond the asset+amount+ + * address checks performed by the caller. + * - `masumi`: verifies the payment locks funds into the Masumi `vested_pay` + * escrow with a valid `FundsLocked` datum matching the seller-signed terms. + * - `script`: the facilitator reconstructs the script credential from the + * declared `script` (+ parameters) or `scriptHash` and confirms it equals + * the script payment credential of `requirements.payTo`. A non-script + * `payTo`, a missing descriptor, or a mismatch is rejected. + * + * @param requirements - The canonical payment requirements. + * @param decoded - The decoded transaction (with output inline datums). + * @param context - Payload, resolved payer and live protocol parameters. + * @param context.payload - The decoded Cardano payload. + * @param context.payer - The address that owns the nonce UTXO. + * @param context.coinsPerUtxoByte - Live `coinsPerUtxoByte`, when available. + * @param context.resource - The protected x402 resource, when available. + * @param context.validateRegistryClaim - Independent registry validator, if any. + * @param context.validateCustomDeployment - Explicit custom deployment validator, if any. + * @returns Result describing success or a precise failure reason. + */ + protected async runMethodSpecificChecks( + requirements: PaymentRequirements, + decoded: DecodedCardanoTransaction, + context: { + payload: ExactCardanoPayload; + payer: string; + coinsPerUtxoByte?: bigint; + validateRegistryClaim?: MasumiRegistryValidator; + resource?: PaymentPayload["resource"]; + validateCustomDeployment?: MasumiDeploymentValidator; + }, + ): Promise<{ ok: true } | { ok: false; reason: string; detail?: string }> { + const extra = requirements.extra; + const method = + (extra as CardanoExtra | undefined)?.assetTransferMethod ?? ASSET_TRANSFER_METHOD_DEFAULT; + if (method === ASSET_TRANSFER_METHOD_DEFAULT) { + if (context.payload.settlementLayer !== undefined || context.payload.headId !== undefined) { + return { ok: false, reason: ERR_SETTLEMENT_LAYER_MISMATCH }; + } + return { ok: true }; + } + if (method === ASSET_TRANSFER_METHOD_MASUMI) { + return verifyMasumiLock(extra, requirements, decoded, context); + } + if (method === ASSET_TRANSFER_METHOD_SCRIPT) { + if (context.payload.settlementLayer !== undefined || context.payload.headId !== undefined) { + return { ok: false, reason: ERR_SETTLEMENT_LAYER_MISMATCH }; + } + const scriptExtra = extra as CardanoExtraScript; + if (!scriptExtra.scriptHash && !scriptExtra.script) { + return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + } + // SECURITY: confirm payTo is the script address implied by the declared + // script + parameters (or scriptHash), so a server cannot redirect the + // payment to an address unrelated to the advertised script. + if (!scriptAddressMatches(scriptExtra, requirements.payTo)) { + return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + } + return { ok: true }; + } + return { ok: false, reason: ERR_UNSUPPORTED_SCHEME }; + } + + /** + * Re-derives the state `settle()` needs from an already-verified payment. + * Pure — no chain lookups — so overriding `verify()` stays the single + * authority on whether a payment is acceptable. + * + * @param payload - The Cardano payment payload. + * @param requirements - The payment requirements. + * @param payer - The payer `verify()` resolved. + * @returns The resolved state, or why it could not be derived. + */ + private resolvePaymentState( + payload: PaymentPayload, + requirements: PaymentRequirements, + payer: string, + ): { ok: true; verified: VerifiedPayment } | { ok: false; reason: string; message?: string } { + let cardanoPayload: ExactCardanoPayload; + try { + cardanoPayload = decodeCardanoPayload(payload.payload as Record); + } catch (cause) { + return { + ok: false, + reason: ERR_INVALID_PAYLOAD, + message: cause instanceof Error ? cause.message : String(cause), + }; + } + let decoded: DecodedCardanoTransaction; + try { + decoded = decodeCardanoTransaction(cardanoPayload.transaction); + } catch (cause) { + return { + ok: false, + reason: ERR_TRANSACTION_DECODE_FAILED, + message: cause instanceof Error ? cause.message : String(cause), + }; + } + const policies = resolveCardanoPolicies(requirements.extra); + if (!policies) return { ok: false, reason: ERR_POLICY_INVALID }; + const mode = normalizeSubmissionMode(cardanoPayload.submissionMode); + if (mode === null || !submissionModeAllowed(policies.submissionPolicy, mode)) { + return { ok: false, reason: ERR_SUBMISSION_MODE_MISMATCH }; + } + return { ok: true, verified: { payload: cardanoPayload, decoded, policies, mode, payer } }; + } + + /** + * Runs verification and keeps the resolved state alongside the response. + * + * @param payload - The Cardano payment payload. + * @param requirements - The payment requirements being fulfilled. + * @returns The verify response plus, on success, the resolved payment state. + */ + private async runVerification( + payload: PaymentPayload, + requirements: PaymentRequirements, + ): Promise<{ response: VerifyResponse; verified?: VerifiedPayment }> { try { if (payload.x402Version !== 2) { return { - isValid: false, - invalidReason: `${ERR_INVALID_PAYLOAD}_unsupported_version`, - payer: "", + response: { + isValid: false, + invalidReason: `${ERR_INVALID_PAYLOAD}_unsupported_version`, + payer: "", + }, }; } if (payload.accepted.scheme !== SCHEME_EXACT || requirements.scheme !== SCHEME_EXACT) { - return { isValid: false, invalidReason: ERR_UNSUPPORTED_SCHEME, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_UNSUPPORTED_SCHEME, payer: "" } }; } if ( normalizeCardanoNetwork(payload.accepted.network) !== normalizeCardanoNetwork(requirements.network) ) { - return { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" } }; } if (!isCardanoNetwork(requirements.network)) { - return { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_NETWORK_MISMATCH, payer: "" } }; + } + if ( + !POSITIVE_CANONICAL_AMOUNT_REGEX.test(requirements.amount) || + !CANONICAL_CARDANO_ASSET_REGEX.test(requirements.asset) + ) { + return { + response: { + isValid: false, + invalidReason: ERR_REQUIREMENTS_INVALID, + invalidMessage: "amount and asset must use their positive canonical wire forms", + payer: "", + }, + }; } let cardanoPayload: ExactCardanoPayload; try { cardanoPayload = decodeCardanoPayload(payload.payload as Record); - } catch { - return { isValid: false, invalidReason: ERR_INVALID_PAYLOAD, payer: "" }; + } catch (cause) { + return { + response: { + isValid: false, + invalidReason: ERR_INVALID_PAYLOAD, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }, + }; + } + + // The submission and confirmation policies always come from the canonical + // server-supplied requirements, never from the client-echoed `accepted`. + const policies = resolveCardanoPolicies(requirements.extra); + if (!policies) { + return { response: { isValid: false, invalidReason: ERR_POLICY_INVALID, payer: "" } }; + } + + // Rule 6: an absent mode normalizes to `server`, and the normalized mode + // MUST be allowed by the selected policy. + const mode = normalizeSubmissionMode(cardanoPayload.submissionMode); + if (mode === null || !submissionModeAllowed(policies.submissionPolicy, mode)) { + return { + response: { isValid: false, invalidReason: ERR_SUBMISSION_MODE_MISMATCH, payer: "" }, + }; } let parsedNonce: { txHash: string; index: number }; try { parsedNonce = parseUtxoRef(cardanoPayload.nonce); } catch { - return { isValid: false, invalidReason: ERR_NONCE_INVALID, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_NONCE_INVALID, payer: "" } }; } let decoded: DecodedCardanoTransaction; @@ -204,98 +720,264 @@ export class ExactCardanoScheme implements SchemeNetworkFacilitator { decoded = decodeCardanoTransaction(cardanoPayload.transaction); } catch (cause) { return { - isValid: false, - invalidReason: ERR_TRANSACTION_DECODE_FAILED, - invalidMessage: cause instanceof Error ? cause.message : String(cause), - payer: "", + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_DECODE_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }, + }; + } + + if (decoded.inputs.length > MAX_CARDANO_TRANSACTION_INPUTS) { + return { + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_PHASE1_INVALID, + invalidMessage: `transaction has ${decoded.inputs.length} inputs; verification permits at most ${MAX_CARDANO_TRANSACTION_INPUTS}`, + payer: "", + }, }; } // Rule 1: network validation. When the body declares a network_id it MUST // match the declared network. Absence of network_id is permitted: the // field is optional in the Cardano CBOR spec and many wallets omit it. - // Network correctness is still enforced by Rule 3 (payTo address check): + // Network correctness is still enforced by Rule 2 (payTo address check): // Cardano addresses are network-tagged (addr_test1... vs addr1...), so a // testnet address cannot be submitted on mainnet and vice versa. const expectedNetworkId = getCardanoNetworkId(requirements.network); if (decoded.networkId !== undefined && decoded.networkId !== expectedNetworkId) { - return { - isValid: false, - invalidReason: ERR_NETWORK_ID_MISMATCH, - payer: "", - }; + return { response: { isValid: false, invalidReason: ERR_NETWORK_ID_MISMATCH, payer: "" } }; } // SECURITY: refuse unsigned transactions in verify() so /verify cannot // return a false-positive that would let callers grant access on an // unpaid request. if (decoded.vkeyWitnessCount === 0 && decoded.scriptWitnessCount === 0) { - return { isValid: false, invalidReason: ERR_TRANSACTION_UNSIGNED, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_TRANSACTION_UNSIGNED, payer: "" } }; } if (!decoded.signaturesValid) { - return { isValid: false, invalidReason: ERR_INVALID_SIGNATURE, payer: "" }; + return { response: { isValid: false, invalidReason: ERR_INVALID_SIGNATURE, payer: "" } }; + } + + // Rule 5 (input check): nonce UTXO MUST appear as an input. + const inputSet = new Set(decoded.inputs.map(i => i.toLowerCase())); + if (inputSet.size !== decoded.inputs.length) { + return { + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_PHASE1_INVALID, + invalidMessage: "transaction contains duplicate inputs", + payer: "", + }, + }; + } + const nonceLower = `${parsedNonce.txHash.toLowerCase()}#${parsedNonce.index}`; + if (!inputSet.has(nonceLower)) { + return { response: { isValid: false, invalidReason: ERR_NONCE_NOT_IN_INPUTS, payer: "" } }; + } + + // A transaction the ledger marked `is_valid = false` is a *failed script* + // transaction: it lands under this exact id but consumes its collateral + // instead of its inputs and creates none of its declared outputs. The + // payment output decoded above would therefore never exist. + if (!decoded.isValid) { + return { + response: { isValid: false, invalidReason: ERR_TRANSACTION_PHASE2_INVALID, payer: "" }, + }; } - // Rule 6 (TTL upper bound) AND lower validity bound: when either is - // declared, fetch the current slot once and check both. The spec only - // calls out TTL explicitly, but a transaction whose lower bound is in - // the future is also not yet valid; without this check verify() could - // return isValid:true even though the chain would refuse the tx. + // Look up authenticated evidence for this exact transaction whenever the + // signer can. Client mode REQUIRES it — the client already broadcast, so + // the facilitator authenticates instead of submitting. Server mode uses it + // to recognize a transaction it already submitted, which is what makes the + // spec's pending-confirmation retry able to resume: once the transaction + // is on-chain its nonce is spent, so the unspent-input precondition below + // no longer applies to it. + let evidence: CardanoSettlementEvidence | undefined; + if (mode === "client" && !this.canAuthenticateEvidence()) { + return { + response: { isValid: false, invalidReason: ERR_EVIDENCE_UNAVAILABLE, payer: "" }, + }; + } + // The `is_valid` flag lives outside the transaction body, so it is not + // covered by the transaction id: a client can broadcast the failing + // (`is_valid = false`) form and hand the facilitator an identical payload + // claiming `true`. Evidence keyed by that id would then point at a + // transaction that created no outputs. A correct evidence provider + // reports such a transaction as unknown, but only a transaction that runs + // a Plutus script can be phase-2 invalid at all — so refusing redeemers + // in client mode closes the hole without depending on the provider. A + // client paying an invoice pays *to* addresses and never needs one. + if (mode === "client" && decoded.redeemerCount > 0 && !this.allowClientScriptExecution) { + return { + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_PHASE2_INVALID, + invalidMessage: + "client-submitted payments must not run Plutus scripts; such a transaction can land phase-2 invalid and create no outputs", + payer: "", + }, + }; + } + if (this.canAuthenticateEvidence()) { + try { + evidence = await this.signer.getTransactionEvidence!( + decoded.txHash, + requirements.network, + ); + } catch (cause) { + // Server mode can still proceed on the unspent-input path; client mode + // has nothing else to stand on. + if (mode === "client") { + return { + response: { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }, + }; + } + } + if ( + mode === "client" && + evidence?.status !== "confirmed" && + evidence?.status !== "mempool" + ) { + return { response: { isValid: false, invalidReason: ERR_EVIDENCE_MISMATCH, payer: "" } }; + } + } + const acceptedByLedger = evidence !== undefined && evidence.status !== "unknown"; + + // Rule 7: TTL. The transaction must not already have expired, and must not + // reach further ahead than `maxTimeoutSeconds`. Slot boundaries are + // converted to wall-clock through the network's era summary rather than + // assuming one slot per second. Once evidence proves the ledger accepted + // the transaction, an elapsed TTL no longer invalidates it. if (decoded.ttlSlot !== undefined || decoded.validityStartSlot !== undefined) { let currentSlot: bigint; try { currentSlot = await this.signer.getCurrentSlot(requirements.network); } catch (cause) { return { - isValid: false, - invalidReason: ERR_CHAIN_LOOKUP_FAILED, - invalidMessage: cause instanceof Error ? cause.message : String(cause), - payer: "", + response: { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }, }; } - if (decoded.ttlSlot !== undefined && decoded.ttlSlot <= currentSlot) { - return { isValid: false, invalidReason: ERR_TTL_EXPIRED, payer: "" }; + if (decoded.ttlSlot !== undefined) { + if (!acceptedByLedger && decoded.ttlSlot <= currentSlot) { + return { response: { isValid: false, invalidReason: ERR_TTL_EXPIRED, payer: "" } }; + } + const ttlMs = slotToPosixMs(requirements.network, decoded.ttlSlot); + const latestMs = + slotToPosixMs(requirements.network, currentSlot) + + requirements.maxTimeoutSeconds * 1000; + if (ttlMs > latestMs) { + return { response: { isValid: false, invalidReason: ERR_TTL_TOO_FAR, payer: "" } }; + } } if (decoded.validityStartSlot !== undefined && decoded.validityStartSlot > currentSlot) { - return { isValid: false, invalidReason: ERR_VALIDITY_NOT_YET_VALID, payer: "" }; + return { + response: { isValid: false, invalidReason: ERR_VALIDITY_NOT_YET_VALID, payer: "" }, + }; } } - // Rule 5 (input check): nonce UTXO MUST appear as an input. - const inputSet = new Set(decoded.inputs.map(i => i.toLowerCase())); - const nonceLower = `${parsedNonce.txHash.toLowerCase()}#${parsedNonce.index}`; - if (!inputSet.has(nonceLower)) { - return { isValid: false, invalidReason: ERR_NONCE_NOT_IN_INPUTS, payer: "" }; - } - - // Rule 5 (chain check) + on-chain pre-check: EVERY transaction input MUST - // currently be unspent. A spent input — the nonce or any coin-selected - // input — guarantees the chain rejects the transaction at submission, so - // resolve them all here rather than only validating payload structure. + // Resolve the nonce UTXO. Before the ledger has accepted the transaction + // its inputs MUST still be unspent — a spent one guarantees the chain + // rejects it at submission. Once accepted, this transaction is what spent + // them, so only the owner address is read (implementations report it even + // for a spent UTXO). let inputSnapshots: CardanoUtxoSnapshot[]; try { - inputSnapshots = await Promise.all( - decoded.inputs.map(ref => this.signer.getUtxo(ref, requirements.network)), - ); + inputSnapshots = []; + for ( + let offset = 0; + offset < decoded.inputs.length; + offset += MAX_CARDANO_INPUT_LOOKUP_CONCURRENCY + ) { + inputSnapshots.push( + ...(await Promise.all( + decoded.inputs + .slice(offset, offset + MAX_CARDANO_INPUT_LOOKUP_CONCURRENCY) + .map(ref => this.signer.getUtxo(ref, requirements.network)), + )), + ); + } } catch (cause) { return { - isValid: false, - invalidReason: ERR_CHAIN_LOOKUP_FAILED, - invalidMessage: cause instanceof Error ? cause.message : String(cause), - payer: "", + response: { + isValid: false, + invalidReason: ERR_CHAIN_LOOKUP_FAILED, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer: "", + }, }; } const nonceSnapshot = inputSnapshots[decoded.inputs.findIndex(ref => ref.toLowerCase() === nonceLower)]; - if (!nonceSnapshot?.exists) { - return { isValid: false, invalidReason: ERR_NONCE_NOT_ON_CHAIN, payer: "" }; + const payer = nonceSnapshot?.address ?? ""; + if (!acceptedByLedger) { + if (!nonceSnapshot?.exists) { + return { + response: { isValid: false, invalidReason: ERR_NONCE_NOT_ON_CHAIN, payer }, + }; + } + if (inputSnapshots.some(snapshot => !snapshot.exists)) { + return { response: { isValid: false, invalidReason: ERR_INPUT_NOT_AVAILABLE, payer } }; + } + } + // Every method resolves the payer from the nonce UTXO's owner, and the + // Masumi datum's `buyer` is matched against it. Failing closed here beats + // letting an empty address flow into a credential comparison. + if (payer.length === 0) { + return { + response: { + isValid: false, + invalidReason: ERR_NONCE_NOT_ON_CHAIN, + invalidMessage: "could not resolve the owner of the nonce UTXO", + payer: "", + }, + }; } - const payer = nonceSnapshot.address ?? ""; - - // Any other input being spent means the transaction cannot settle. - if (inputSnapshots.some(snapshot => !snapshot.exists)) { - return { isValid: false, invalidReason: ERR_INPUT_NOT_AVAILABLE, payer }; + // In server mode the protected handler can run before submitTransaction, + // so an approximation is unsafe: fee, size and other live protocol rules + // can still make an otherwise balanced transaction ledger-invalid. Require + // a complete phase-1 validator. Client mode already has authenticated + // ledger acceptance evidence for this exact transaction. + if (!acceptedByLedger) { + if (!this.signer.validatePhase1Transaction) { + return { + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_PHASE1_INVALID, + invalidMessage: "server submission requires a complete Cardano phase-1 validator", + payer, + }, + }; + } + try { + await this.signer.validatePhase1Transaction( + cardanoPayload.transaction, + requirements.network, + ); + } catch (cause) { + return { + response: { + isValid: false, + invalidReason: ERR_TRANSACTION_PHASE1_INVALID, + invalidMessage: cause instanceof Error ? cause.message : String(cause), + payer, + }, + }; + } } // Rules 2, 3, 4: at least one output MUST pay the requested amount of @@ -320,270 +1002,322 @@ export class ExactCardanoScheme implements SchemeNetworkFacilitator { } assetFoundForRecipient = true; if (available > bestAvailable) bestAvailable = available; - if (available >= requestedAmount) { - // Fetch the live coinsPerUtxoByte once (governance-settable): it feeds - // both the generic min-UTXO check and the Masumi post-result min-UTXO - // check. Undefined when the signer does not expose the hook. - let coinsPerUtxoByte: bigint | undefined; - if (typeof this.signer.getCoinsPerUtxoByte === "function") { - try { - coinsPerUtxoByte = await this.signer.getCoinsPerUtxoByte(requirements.network); - } catch (cause) { - return { + if (available < requestedAmount) { + continue; + } + // Fetch the live coinsPerUtxoByte once (governance-settable): it feeds + // both the generic min-UTXO check and the Masumi post-result min-UTXO + // check. Undefined when the signer does not expose the hook. + let coinsPerUtxoByte: bigint | undefined; + if (typeof this.signer.getCoinsPerUtxoByte === "function") { + try { + coinsPerUtxoByte = await this.signer.getCoinsPerUtxoByte(requirements.network); + } catch (cause) { + return { + response: { isValid: false, invalidReason: ERR_CHAIN_LOOKUP_FAILED, invalidMessage: cause instanceof Error ? cause.message : String(cause), payer, - }; - } + }, + }; } - // Reject outputs below the protocol min-UTXO (the node would refuse - // them at submission). Skipped when coinsPerUtxoByte or the serialized - // size is unavailable. - if (coinsPerUtxoByte !== undefined && output.serializedSize !== undefined) { - const minUtxo = minUtxoLovelace(output.serializedSize, coinsPerUtxoByte); - if (output.coin < minUtxo) { - return { + } + // Rule 8: reject outputs below the protocol min-UTXO (the node would + // refuse them at submission). Skipped when coinsPerUtxoByte or the + // serialized size is unavailable. + if (coinsPerUtxoByte !== undefined && output.serializedSize !== undefined) { + const minUtxo = minUtxoLovelace(output.serializedSize, coinsPerUtxoByte); + if (output.coin < minUtxo) { + return { + response: { isValid: false, invalidReason: ERR_MIN_UTXO_INSUFFICIENT, invalidMessage: `output to ${requirements.payTo} carries ${output.coin} lovelace, min-UTXO requires ${minUtxo}`, payer, - }; - } - } - // SECURITY: Read assetTransferMethod from the canonical - // server-supplied requirements, NOT from payload.accepted.extra - // (which is client-echoed and could lie about the method to - // bypass script-mode reconstruction checks). - const methodCheck = await this.runMethodSpecificChecks( - requirements.extra, - requirements, - decoded, - payer, - coinsPerUtxoByte, - ); - if (!methodCheck.ok) { - return { isValid: false, invalidReason: methodCheck.reason, payer }; + }, + }; } - // Optional Plutus-script dry-run. `evaluateTransaction` computes - // script execution units (Ogmios evaluateTransaction / Blockfrost - // /utils/txs/evaluate); it does NOT validate vkey signatures. It only - // adds a guard for script-mode payments, so it is a no-op for the - // simple address-to-address transfers this base class accepts. - // Skipped when the signer does not implement it; either way vkey - // signatures are enforced only by the node at submit time (settle). - if (typeof this.signer.evaluateTransaction === "function") { - try { - await this.signer.evaluateTransaction( - cardanoPayload.transaction, - requirements.network, - ); - } catch (cause) { - return { + } + // SECURITY: Read assetTransferMethod from the canonical + // server-supplied requirements, NOT from payload.accepted.extra + // (which is client-echoed and could lie about the method to + // bypass script-mode reconstruction checks). + const methodCheck = await this.runMethodSpecificChecks(requirements, decoded, { + payload: cardanoPayload, + payer, + coinsPerUtxoByte, + validateRegistryClaim: this.validateRegistryClaim, + resource: payload.resource, + validateCustomDeployment: this.validateCustomMasumiDeployment, + }); + if (!methodCheck.ok) { + return { + response: { + isValid: false, + invalidReason: methodCheck.reason, + ...(methodCheck.detail ? { invalidMessage: methodCheck.detail } : {}), + payer, + }, + }; + } + // Optional Plutus-script dry-run. `evaluateTransaction` computes + // script execution units (Ogmios evaluateTransaction / Blockfrost + // /utils/txs/evaluate); it does NOT validate vkey signatures. It only + // adds a guard for script-mode payments, so it is a no-op for the + // simple address-to-address transfers this base class accepts. A + // client-submitted transaction is already on the ledger, so a dry-run + // against the current UTXO set would fail on its own spent inputs. + if (!acceptedByLedger && typeof this.signer.evaluateTransaction === "function") { + try { + await this.signer.evaluateTransaction(cardanoPayload.transaction, requirements.network); + } catch (cause) { + return { + response: { isValid: false, invalidReason: ERR_CHAIN_LOOKUP_FAILED, invalidMessage: cause instanceof Error ? cause.message : String(cause), payer, - }; - } + }, + }; } - return { isValid: true, payer }; } + if (policies.confirmationPolicy.l1Confirmations > 0 && !this.canAuthenticateEvidence()) { + return { + response: { + isValid: false, + invalidReason: ERR_EVIDENCE_UNAVAILABLE, + invalidMessage: + "confirmation depth above canonical inclusion requires transaction evidence", + payer, + }, + }; + } + return { + response: { isValid: true, payer }, + verified: { payload: cardanoPayload, decoded, policies, mode, payer }, + }; } if (!recipientFound) { - return { isValid: false, invalidReason: ERR_RECIPIENT_MISMATCH, payer }; + return { response: { isValid: false, invalidReason: ERR_RECIPIENT_MISMATCH, payer } }; } if (!assetFoundForRecipient) { - return { isValid: false, invalidReason: ERR_ASSET_MISMATCH, payer }; + return { response: { isValid: false, invalidReason: ERR_ASSET_MISMATCH, payer } }; } return { - isValid: false, - invalidReason: ERR_AMOUNT_INSUFFICIENT, - invalidMessage: `output to ${requirements.payTo} pays ${bestAvailable}, requires ${requestedAmount}`, - payer, + response: { + isValid: false, + invalidReason: ERR_AMOUNT_INSUFFICIENT, + invalidMessage: `output to ${requirements.payTo} pays ${bestAvailable}, requires ${requestedAmount}`, + payer, + }, }; } catch (error) { return { - isValid: false, - invalidReason: `${ERR_INVALID_PAYLOAD}_verification_error`, - invalidMessage: error instanceof Error ? error.message : String(error), - payer: "", + response: { + isValid: false, + invalidReason: `${ERR_INVALID_PAYLOAD}_verification_error`, + invalidMessage: error instanceof Error ? error.message : String(error), + payer: "", + }, }; } } /** - * Settles a Cardano payment by re-verifying and submitting the transaction. + * Turns settlement evidence into a settle response, applying the confirmation + * policy and the operator's mempool opt-in. * - * @param payload - The Cardano payment payload. - * @param requirements - The payment requirements. - * @returns A settle response describing success or failure. + * @param evidence - The strongest verified evidence. + * @param txHash - The canonical transaction id. + * @param network - The network to report. + * @param mode - The normalized submission mode. + * @param required - The `l1Confirmations` threshold. + * @param verified - The resolved payment state. + * @returns The settle response. */ - async settle( - payload: PaymentPayload, - requirements: PaymentRequirements, - ): Promise { - const verifyResult = await this.verify(payload, requirements); - if (!verifyResult.isValid) { + private evidenceResponse( + evidence: CardanoSettlementEvidence, + txHash: string, + network: Network, + mode: CardanoSubmissionMode, + required: number, + verified: VerifiedPayment, + ): SettleResponse { + const status = evidence.status === "confirmed" ? "confirmed" : "mempool"; + const extra: Record = { + status, + submissionMode: mode, + confirmations: evidence.confirmations, + ...(verified.payload.settlementLayer + ? { settlementLayer: verified.payload.settlementLayer } + : {}), + ...(verified.payload.headId ? { headId: verified.payload.headId } : {}), + }; + + if (evidence.status === "unknown") { return { success: false, - errorReason: verifyResult.invalidReason ?? "verification_failed", - transaction: "", - network: payload.accepted.network, + errorReason: ERR_EVIDENCE_MISMATCH, + transaction: txHash, + network, + payer: verified.payer, + extra: { ...extra, status: "pending" }, }; } - - const cardanoPayload = decodeCardanoPayload(payload.payload as Record); - const cacheKey = cardanoPayload.transaction; - // Atomically claim the cache key so concurrent settle() calls do not all - // pass the duplicate check before the first await on submitTransaction(). - if (!this.tryClaim(cacheKey)) { + // Mempool inclusion can be rolled back, so refuse it unless the operator + // explicitly opted in, even when the policy would allow `-1`. + if (evidence.status === "mempool" && !this.acceptMempool) { return { success: false, - errorReason: ERR_DUPLICATE_SETTLEMENT, - transaction: "", - network: payload.accepted.network, + errorReason: ERR_SETTLEMENT_NOT_CONFIRMED, + transaction: txHash, + network, + payer: verified.payer, + extra, }; } - - try { - const submission = await this.signer.submitTransaction( - cardanoPayload.transaction, - requirements.network, - ); - - // Honor `acceptMempool`: when the signer reports only mempool inclusion - // and the operator has not opted in, refuse to call the payment settled. - // We keep the claim in place even on rejection so retries cannot - // rebroadcast the same transaction repeatedly. - if (submission.status !== "confirmed" && !this.acceptMempool) { - return { - success: false, - errorReason: ERR_SETTLEMENT_NOT_CONFIRMED, - transaction: submission.txHash, - network: payload.accepted.network, - payer: verifyResult.payer, - extra: { status: submission.status }, - }; - } - - return { - success: true, - transaction: submission.txHash, - network: payload.accepted.network, - payer: verifyResult.payer, - extra: { status: submission.status }, - }; - } catch (cause) { - // Submission threw — a node rejection at submit (e.g. BadInputsUTxO when an - // input was already spent), an await-confirmation timeout, or a - // network/deserialization error. Surface the underlying reason via - // errorMessage so callers are not left with only the generic code, and - // free the claim so a legitimate retry can re-attempt. - this.releaseClaim(cacheKey); + if (!confirmationsSatisfy(evidence.confirmations, required)) { return { success: false, - errorReason: ERR_SETTLEMENT_FAILED, - errorMessage: describeErrorChain(cause), - transaction: "", - network: payload.accepted.network, + errorReason: ERR_PAYMENT_PENDING, + transaction: txHash, + network, + payer: verified.payer, + extra: { ...extra, status: "pending", transactionId: txHash }, }; } + return { + success: true, + transaction: txHash, + network, + payer: verified.payer, + extra, + }; } /** - * Runs the verification step that depends on the assetTransferMethod - * declared in `requirements.extra`. + * Polls the evidence hook until the threshold is met or the confirmation + * timeout elapses, returning the strongest evidence seen. * - * - `default` / undefined: no extra verification beyond the asset+amount+ - * address checks performed by the caller. - * - `masumi`: verifies the payment locks funds into the Masumi `vested_pay` - * escrow with a valid `FundsLocked` datum matching the requirements - * (see `verifyMasumiLock`). Only the lock is checked (x402's scope). - * - `script`: the facilitator reconstructs the script credential from the - * declared `script` (+ parameters) or `scriptHash` and confirms it equals - * the script payment credential of `requirements.payTo`. A non-script - * `payTo`, a missing descriptor, or a mismatch is rejected. - * - * @param extra - The canonical requirements' extra block. - * @param requirements - The canonical payment requirements. - * @param decoded - The decoded transaction (with output inline datums). - * @param payer - The resolved payer address. - * @param coinsPerUtxoByte - Live `coinsPerUtxoByte`, forwarded to the Masumi - * check for its post-result min-UTXO enforcement. Undefined when unavailable. - * @returns Result describing success or a precise failure reason. + * @param txHash - The canonical transaction id. + * @param network - The x402 network identifier. + * @param required - The `l1Confirmations` threshold. + * @returns The strongest verified evidence. */ - protected async runMethodSpecificChecks( - extra: Record | undefined, - requirements: PaymentRequirements, - decoded: DecodedCardanoTransaction, - payer: string, - coinsPerUtxoByte?: bigint, - ): Promise<{ ok: true } | { ok: false; reason: string }> { - const method = - (extra as CardanoExtra | undefined)?.assetTransferMethod ?? ASSET_TRANSFER_METHOD_DEFAULT; - if (method === ASSET_TRANSFER_METHOD_DEFAULT) { - return { ok: true }; - } - if (method === ASSET_TRANSFER_METHOD_MASUMI) { - return verifyMasumiLock( - extra as CardanoExtraMasumi, - requirements, - decoded, - payer, - coinsPerUtxoByte, - ); + private async awaitEvidence( + txHash: string, + network: string, + required: number, + ): Promise { + if (!this.canAuthenticateEvidence()) { + return { status: "unknown", confirmations: MIN_L1_CONFIRMATIONS - 1 }; } - if (method === ASSET_TRANSFER_METHOD_SCRIPT) { - const scriptExtra = extra as CardanoExtraScript; - if (!scriptExtra.scriptHash && !scriptExtra.script) { - return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + const deadline = Date.now() + this.confirmationTimeoutMs; + let latest: CardanoSettlementEvidence = { + status: "unknown", + confirmations: MIN_L1_CONFIRMATIONS - 1, + }; + for (;;) { + try { + latest = await this.signer.getTransactionEvidence!(txHash, network); + } catch { + // A transient provider error must not be reported as absent evidence; + // keep the strongest result seen so far and retry until the deadline. } - // SECURITY: confirm payTo is the script address implied by the declared - // script + parameters (or scriptHash), so a server cannot redirect the - // payment to an address unrelated to the advertised script. - if (!scriptAddressMatches(scriptExtra, requirements.payTo)) { - return { ok: false, reason: ERR_SCRIPT_ADDRESS_MISMATCH }; + if (latest.status !== "unknown" && confirmationsSatisfy(latest.confirmations, required)) { + return latest; } - return { ok: true }; + if (Date.now() + this.confirmationPollMs >= deadline) return latest; + await new Promise(resolve => setTimeout(resolve, this.confirmationPollMs)); } - return { ok: false, reason: ERR_UNSUPPORTED_SCHEME }; } /** - * Atomically claim a cache key for an in-flight or completed settlement. - * Synchronous so concurrent settle() calls cannot all race past the check. + * Whether the signer can authenticate settlement evidence for a transaction. * - * @param key - Cache key, typically the base64-encoded transaction. - * @returns True when the claim was acquired; false when the key was already - * claimed within the duplicate-settlement window. + * @returns True when the optional evidence hook is implemented. */ - private tryClaim(key: string): boolean { - const seenAt = this.settlementCache.get(key); - const now = Date.now(); - if (seenAt !== undefined && now - seenAt <= this.duplicateCacheTtlMs) { - return false; - } - this.settlementCache.set(key, now); - if (this.settlementCache.size > 1024) { - const cutoff = now - this.duplicateCacheTtlMs; - for (const [k, t] of this.settlementCache) { - if (t < cutoff) this.settlementCache.delete(k); - } - } - return true; + private canAuthenticateEvidence(): boolean { + return typeof this.signer.getTransactionEvidence === "function"; + } + + /** + * Returns the canonical Masumi terms digest, when this is a Masumi payment. + * + * @param requirements - Accepted payment requirements. + * @returns Canonical terms digest, or undefined for another transfer method. + */ + private masumiTermsDigest(requirements: PaymentRequirements): string | undefined { + const extra = requirements.extra as CardanoExtra | undefined; + if (extra?.assetTransferMethod !== ASSET_TRANSFER_METHOD_MASUMI) return undefined; + const schema = validateMasumiExtra(extra, requirements.network); + return schema.ok ? computeTermsDigest(buildSignedTerms(schema.extra, requirements)) : undefined; + } + + /** + * Atomically claim a canonical transaction id for submission. Synchronous so + * concurrent settle() calls cannot all race past the check. + * + * - `fresh` — nothing claimed this transaction; the caller submits it. + * - `in-flight` — another call is mid-submission; this is the race the + * duplicate-settlement mitigation exists for, and it is refused. + * - `submitted` — this exact transaction was already broadcast. The caller + * MUST NOT submit it again, but the spec's pending-confirmation retry has to + * resume observing it, so this is not a rejection. + * - `rejected` — the node definitively rejected these bytes; never resubmit. + * - `mode-conflict` — a retry for this transaction arrived under the other + * normalized submission mode, which the spec forbids. + * + * @param txHash - The canonical Cardano transaction id. + * @param mode - The normalized submission mode this settlement uses. + * @param ownerToken - Unpredictable token that owns a fresh claim. + * @param termsDigest - Optional Masumi terms binding. + * @returns The claim outcome. + */ + private async claimSettlement( + txHash: string, + mode: CardanoSubmissionMode, + ownerToken: string, + termsDigest?: string, + ): Promise< + | "fresh" + | "in-flight" + | "submitted" + | "rejected" + | "mode-conflict" + | "terms-conflict" + | "capacity-exceeded" + > { + return this.settlementStore.claimSettlement({ + txHash, + mode, + ownerToken, + ...(termsDigest ? { termsDigest } : {}), + }); } /** - * Releases a previously-claimed cache key so retries can attempt - * settlement again. Called when submission throws a transient error. + * Marks a claimed transaction as broadcast, so a later retry resumes + * observing it instead of submitting it again. * - * @param key - Cache key, typically the base64-encoded transaction. + * @param txHash - The canonical Cardano transaction id. + * @param ownerToken - Token that owns the claim. * @returns Nothing. */ - private releaseClaim(key: string): void { - this.settlementCache.delete(key); + private async markSubmitted(txHash: string, ownerToken: string): Promise { + await this.settlementStore.markSubmitted(txHash, ownerToken); + } + + /** + * Permanently records a definitive pre-ledger rejection. + * + * @param txHash - Canonical transaction ID. + * @param ownerToken - Token that owns the claim. + */ + private async markRejected(txHash: string, ownerToken: string): Promise { + await this.settlementStore.markRejected(txHash, ownerToken); } } diff --git a/typescript/packages/mechanisms/cardano/src/exact/facilitator/scriptAddress.ts b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scriptAddress.ts index 05498a5807..d74e02d12e 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/facilitator/scriptAddress.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/facilitator/scriptAddress.ts @@ -13,6 +13,15 @@ import type { CardanoScriptDescriptor, CardanoScriptParameter, } from "../../types"; +import { unwrapCborByteString } from "../../utils"; +import { + MAX_CARDANO_SCRIPT_BYTES, + MAX_CARDANO_SCRIPT_PARAMETERS, + MAX_CARDANO_SCRIPT_PARAMETER_BYTES, +} from "../../limits"; + +const SCRIPT_HASH_REGEX = /^[0-9a-f]{56}$/; +const EVEN_HEX_REGEX = /^(?:[0-9a-f]{2})+$/; /** * Reconstructs the script payment-credential (script hash) implied by a script @@ -74,20 +83,91 @@ function scriptPaymentCredentialHex(payTo: string): string | null { * @returns The derived script hash hex. * @throws When neither `script` nor `scriptHash` is usable. */ -function deriveScriptHashHex(extra: CardanoExtraScript): string { +export function deriveScriptHashHex(extra: CardanoExtraScript): string { if (extra.script?.code) { - const params = extra.parameters ? Object.values(extra.parameters).map(toPlutusData) : []; + if ( + !EVEN_HEX_REGEX.test(extra.script.code) || + extra.script.code.length / 2 > MAX_CARDANO_SCRIPT_BYTES + ) { + throw new Error("Cardano script code is invalid or exceeds the byte limit"); + } + const entries = extra.parameters ? Object.entries(extra.parameters) : []; + if (entries.length > MAX_CARDANO_SCRIPT_PARAMETERS) { + throw new Error("Cardano script has too many parameters"); + } + let parameterBytes = 0; + const params = entries.map(([name, parameter]) => { + parameterBytes += Buffer.byteLength(name, "utf8") + parameterInputBytes(parameter); + if (parameterBytes > MAX_CARDANO_SCRIPT_PARAMETER_BYTES) { + throw new Error("Cardano script parameters exceed the byte limit"); + } + return toPlutusData(parameter); + }); const applied = UPLC.applyParamsToScript(extra.script.code, params); const raw = unwrapCborByteString(applied); const script = makePlutusScript(extra.script.type, raw); return ScriptHash.toHex(ScriptHash.fromScript(script)).toLowerCase(); } if (extra.scriptHash) { + if (!SCRIPT_HASH_REGEX.test(extra.scriptHash)) { + throw new Error("Cardano scriptHash must be 28-byte lowercase hex"); + } return extra.scriptHash.toLowerCase(); } throw new Error("Cardano script payment requires either `script` or `scriptHash`"); } +/** + * Measures one scalar parameter before conversion allocates Plutus data. + * + * @param param - Declared script parameter. + * @returns Approximate source bytes consumed by the value. + */ +function parameterInputBytes(param: CardanoScriptParameter): number { + if (!param || typeof param !== "object") { + throw new Error("Cardano script parameter must be an object"); + } + switch (param.type) { + case "bytes": { + if (typeof param.value !== "string" || !/^(?:[0-9a-f]{2})*$/.test(param.value)) { + throw new Error("Cardano bytes parameter must be lowercase even-length hex"); + } + return param.value.length / 2; + } + case "string": + if (typeof param.value !== "string") { + throw new Error("Cardano string parameter must carry a string"); + } + return Buffer.byteLength(param.value, "utf8"); + case "bigint": + case "integer": { + const value = param.value; + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error("Cardano integer parameter must be a safe integer"); + } + if ( + typeof value !== "bigint" && + typeof value !== "number" && + (typeof value !== "string" || !/^(?:0|[1-9]\d*|-[1-9]\d*)$/.test(value)) + ) { + throw new Error("Cardano integer parameter must use canonical decimal syntax"); + } + const digits = String(value).replace(/^-/, ""); + if (digits.length > 128) { + throw new Error("Cardano integer parameter exceeds the digit limit"); + } + return digits.length; + } + case "boolean": + if (typeof param.value !== "boolean") { + throw new Error("Cardano boolean parameter must carry a boolean"); + } + return 1; + default: + throw new Error(`Unsupported Cardano script parameter type: ${param.type}`); + } +} + /** * Converts a typed script parameter into Plutus `Data`. Only the scalar types * the reference signer emits are supported; nested constr/list/map parameters @@ -133,37 +213,3 @@ function makePlutusScript( return new PlutusV3.PlutusV3({ bytes }); } } - -/** - * Strips a single definite-length CBOR byte-string wrapper, returning the - * payload bytes. `applyParamsToScript` returns the script wrapped in one such - * layer; the script hash is computed over the unwrapped bytes. - * - * @param hex - Hex of a definite-length CBOR byte string. - * @returns The unwrapped payload bytes. - */ -function unwrapCborByteString(hex: string): Uint8Array { - const bytes = Buffer.from(hex, "hex"); - if (bytes.length === 0 || bytes[0] >> 5 !== 2) { - throw new Error("Expected a CBOR byte string from applyParamsToScript"); - } - const additional = bytes[0] & 0x1f; - let length: number; - let offset: number; - if (additional < 24) { - length = additional; - offset = 1; - } else if (additional === 24) { - length = bytes[1]; - offset = 2; - } else if (additional === 25) { - length = bytes.readUInt16BE(1); - offset = 3; - } else if (additional === 26) { - length = bytes.readUInt32BE(1); - offset = 5; - } else { - throw new Error("Unsupported CBOR byte-string length encoding"); - } - return Uint8Array.from(bytes.subarray(offset, offset + length)); -} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprint.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprint.ts new file mode 100644 index 0000000000..fcdd92eed7 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprint.ts @@ -0,0 +1,121 @@ +import { + Address, + Data, + EnterpriseAddress, + PlutusV3, + ScriptHash, + UPLC, +} from "@evolution-sdk/evolution"; + +import { + CARDANO_PREVIEW_CAIP2, + getCardanoNetworkId, + normalizeCardanoNetwork, +} from "../../constants"; +import type { MasumiDeployment } from "../../types"; +import { unwrapCborByteString } from "../../utils"; +import { MAX_MASUMI_SCRIPT_HASH_CACHE_ENTRIES } from "../../limits"; +import { MASUMI_VESTED_PAY_COMPILED_CODE } from "./blueprintCode"; + +/** + * Derivation of the deployment-specific `vested_pay` escrow address. + * + * The validator parameters are baked into the script hash, so a different + * parameterization is a different address — and a look-alike `vested_pay` with + * different admins is a different trust domain. The verifier therefore derives + * the address itself from the canonical compiled validator and requires it to + * equal `payTo`; `payTo` is never defaulted or inferred. + */ + +/** `SHA-256(JCS(blueprint))` of the pinned canonical blueprint document. */ +export const MASUMI_BLUEPRINT_DIGEST = + "6249de17bb87c5246106af6b0f33de22b44ca24b9c1445fa36d10eb8b583dec7"; + +/** CIP-57 validator title this scheme locks into. */ +export const MASUMI_VALIDATOR_TITLE = "vested_pay.vested_pay.spend"; + +/** Scheme-level datum schema version for the escrow datum. */ +export const MASUMI_DATUM_SCHEMA_VERSION = "masumi.vested_pay.v2"; + +/** + * Canonical deployment parameters. Mainnet and Preprod default to these when + * `extra.deployment` is absent; Preview has no canonical deployment and always + * requires an explicit one. + */ +export const MASUMI_DEFAULT_DEPLOYMENT: MasumiDeployment = { + requiredAdmins: "2", + adminVkeys: [ + "fc16a1fcf309aed03ec18bb2176f5ea29acea70bb79145ebaffa8e75", + "7f78161369549d8e2b138fee724c9fa606d6107a66720bdb4c48ada6", + "89eef9ea84e0ee7fe4921fa93eb2873ff6e34473f751d5d52cb75aa6", + ], + cooldownPeriod: "420000", +}; + +/** Applying parameters + hashing is pure, so memoize it per parameterization. */ +const scriptHashCache = new Map(); + +/** + * Resolves which deployment parameters apply to a payment. + * + * @param network - The x402 Cardano network identifier (or a CIP-34 alias). + * @param declared - The `extra.deployment` block, when present. + * @returns The deployment to apply, or `null` when the network has no canonical + * default and none was declared. + */ +export function resolveMasumiDeployment( + network: string, + declared: MasumiDeployment | undefined, +): MasumiDeployment | null { + if (declared) return declared; + return normalizeCardanoNetwork(network) === CARDANO_PREVIEW_CAIP2 + ? null + : MASUMI_DEFAULT_DEPLOYMENT; +} + +/** + * Derives the escrow validator hash for a deployment by applying its three + * parameters to the canonical compiled validator. Admin key order and + * duplicates are preserved — a repeated key carries repeated voting weight and + * changes the hash. + * + * @param deployment - The deployment parameters. + * @returns The lowercase hex script hash. + */ +export function masumiEscrowScriptHash(deployment: MasumiDeployment): string { + const cacheKey = `${deployment.requiredAdmins}|${deployment.adminVkeys.join(",")}|${deployment.cooldownPeriod}`; + const cached = scriptHashCache.get(cacheKey); + if (cached) return cached; + + const applied = UPLC.applyParamsToScript(MASUMI_VESTED_PAY_COMPILED_CODE, [ + Data.int(BigInt(deployment.requiredAdmins)), + Data.list(deployment.adminVkeys.map(vkey => Data.bytearray(vkey))), + Data.int(BigInt(deployment.cooldownPeriod)), + ]); + const script = new PlutusV3.PlutusV3({ bytes: unwrapCborByteString(applied) }); + const hash = ScriptHash.toHex(ScriptHash.fromScript(script)).toLowerCase(); + if (scriptHashCache.size >= MAX_MASUMI_SCRIPT_HASH_CACHE_ENTRIES) { + const oldest = scriptHashCache.keys().next().value; + if (oldest !== undefined) scriptHashCache.delete(oldest); + } + scriptHashCache.set(cacheKey, hash); + return hash; +} + +/** + * Derives the bech32 escrow address for a deployment on a network. + * + * @param network - The x402 Cardano network identifier (or a CIP-34 alias). + * @param deployment - The deployment parameters; defaults to the canonical set. + * @returns The bech32 enterprise script address of the escrow. + */ +export function masumiEscrowAddress( + network: string, + deployment: MasumiDeployment = MASUMI_DEFAULT_DEPLOYMENT, +): string { + const enterprise = new EnterpriseAddress.EnterpriseAddress({ + networkId: getCardanoNetworkId(network), + paymentCredential: ScriptHash.fromHex(masumiEscrowScriptHash(deployment)), + }); + return Address.toBech32(enterprise as unknown as Address.Address); +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprintCode.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprintCode.ts new file mode 100644 index 0000000000..cb2b2084cb --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/blueprintCode.ts @@ -0,0 +1,16 @@ +/** + * Canonical CIP-57 blueprint artifact for the Masumi `vested_pay` escrow. + * + * Taken verbatim from `masumi-payment-service` at commit + * `d74b2c319228bcbef36632de37875c388dcee7ce` + * (`smart-contracts/payment-v2/plutus.json`), validator title + * `vested_pay.vested_pay.spend`, Plutus `v3`. The blueprint that contains it + * has `SHA-256(JCS(blueprint))` equal to {@link MASUMI_BLUEPRINT_DIGEST}. + * + * This is the **un-applied** validator: `required_admins_multi_sig`, + * `admin_vks` and `cooldown_period` are still parameters, so its own hash is + * not an escrow address. Applying a parameterization yields the deployment hash + * (see `masumiEscrowScriptHash`). + */ +export const MASUMI_VESTED_PAY_COMPILED_CODE = + "5926a101010022229800aba4aba2aba1aba0aab9faab9eaab9dab9a9bad0049bac0039bad0024888888888896600264653001300b00198059806000cdc3a4005300b0024888966002600460166ea800e33001300c3754007370e90024dc3a400d370e90044dc3a4015370e9000488c8cc00400400c88cc00c004c00800a60166ea8011222222223322325980098030024566002602e6ea803e00316406115980098068024566002602e6ea803e00316406115980098050024566002602e6ea803e00316406115980098048024566002602e6ea803e003164061159800980400244c8c8c8cc8966002604000713300a3756603e00a44b3001002899806001912cc00400a26601c00c44b300100280644c966002602460446ea800626464653001375c6052003375c6052007375c60520049112cc004c0b401226010605a0131640a83029001302800130233754003164084604a004811a26464660206eacc08800889660020051300530280068991991180218158029bae3024001375a604a002604e0048128dd718100009811801204289919198071bab3020002225980080144c014c09801a26466446008605200a6eb8c088004dd6981180098128012046375c603c002604200480fa2c80e8dd6180e8009bab301d002301d001301c0013017375401f15980098038024566002602e6ea803e00316406115980099b87480300122b30013017375401f0018b20308b202a405480a9015202a405480a8566002600a602a6ea800626464646464646464646464646464646464653001375a6056003302b302c0019b89480026056025302b01198158084c0ac03e6eb8c0ac03a6eb8c0ac0366eb8c0ac0326eb8c0ac02e6eb8c0ac02a6eb4c0ac0266eb8c0ac0226eb8c0ac01e6eb4c0ac01a6eb4c0ac0166eb4c0ac0126eb4c0ac00e6eb4c0ac00922222222222222222222598009809004456600266e252020371a019133223259800981a981f1baa00189919912cc004c0c4c104dd5001466002608a60846ea800a44646600200200644b30010018a5eb8226644b3001300500289982500119802002000c4cc01001000504618248009825000a08e9182318239823982398239823800c8c118c11cc11c00644646600200200644b30010018a5eb8226644b300130050028998251ba900233004004001899802002000a08c375c60920026094002823a6e012002488888a6002609660906ea8c02cc120dd50034896600200314bd7044c8cc134dd48009980180199802982780114c004cdc7800801528528a094375c609a002825a44b30010018a40011300333002002304e001412c9114c004c8cc004004cc024dd6182798261baa04123375e60a0609a6ea8c040c134dd5000802912cc004006297ae089919912cc004c118c13cdd500144cc0140140062660a460a660a06ea8008cc01401400504e192cc004c108c138dd5000c4cc8966002005132323322598009822182a1baa0068992cc00400600d13259800800c4c9660020030088992cc004006264b300100180544c96600200313259800800c032264b30010018992cc00400601d13259800800c03e01f00f807c4cc89660020030118992cc004006025012809404a26644b300100180a44c96600200301580ac05602b1332259800800c05e264b300100180c4062031018899912cc00400603513259800800c06e03701b80dc4cc896600200301d8992cc00400603d01e80f44cc89660020030208992cc004006043021810c08626644b3001001811c4c96600200302481240920491332259800800c09a264b3001001813c09e04f1332259800800c0a6264b300100181540aa0551332259800800c0b2264b3001001816c0b605b1332259800800c0be264b300100181840c20611332259800800c0ca264b3001001819c0ce0671332259800800c0d6264b300100181b40da06d1332259800800c0e2264b30010018acc004c2340400a330010338cc0040c63300102f8cc0040b6264b300130790018acc004c22804dd500140da07484580a2b30013080010018acc004c22804dd500140da07484580a2b3001307d0018acc004c22804dd500140da07484580a2b3001307c0018acc004c22804dd500140da07484580a2b3001307b0018acc004c22804dd500140da07484580a2b3001307a0018acc004c22804dd500140da07484580a07484400908801211002422004844009088011844009baa00181ca06c81ca06e81ca06c81ca06e81ca1140281cc0e6073039423804611602002844808dd680098450080140d908b01184400800a10c02375a002610e02005033422004610a02002841808dd680098420080140c108501184100800a10002375a00261020200502d42080460fe00283e8dd6800983f00140a907f183e000a0f4375a00260f600502741f060f200283b8dd7000983c00120f2307600141d06eb8004c1d40090761839800a0e2375a00260e400501e41cc60e00028370dd7000983780120e0306d00141ac6eb8004c1b000906d1835000a0d0375c00260d20048350c19c0050651bae0013066002419c60c80028310dd7000983180120c83061001417c60c200500d806c03601a8310c17c00505d182f801402e01700b805a0c0305d001416c60ba005009804c02601282f0c16c005059182d801401e00f007803a0b83059001415c60aa6ea801a00a8298888c966002608c00313259800800c00e264b30010018acc004c17400a33001001802c011007401105a401200900480220bc305b001416460ae6ea80122b3001304d0018acc004c15cdd5002400e00482c200482a9055182a9baa0031301433055300f3053375400897ae0222598009822182a1baa0038992cc00400600513259800800c4c9660020030048992cc0040062b3001305d0028cc00400e264b300130490018992cc00400600f13259800800c56600260c0005132598009826000c4c96600200300a8992cc0040062b300130630028cc00400601900b403900b418100b805c02e0168320c18400505f182e9baa0028acc004c14c006264b300100180544c96600200300b805c02e26644b3001001806c4c96600200300e807403a26644b300100180844c966002003011808c046264b3001306a003809c0490671bad001808a0d4306700141946eb4004c19800a01c8338c1900050621bad0013063002805a0c83061001417c60ba6ea800a01282d905b182d9baa00180420ba8044022011008418460bc00282e0c168dd5001456600260a0003159800982d1baa002803c01905b401905820b03058375400300540210054169005802c01600a82f0c16c005059182d801400e007003801a0b83059001415c60aa6ea800e0028298888c966002608800313259800800c00e264b300100180240120090048992cc004c17000e00d00541646eb800505c182c800a0ae305537540091598009825800c4c9660020030038992cc0040060090048024012264b3001305c00380340150591bae001417060b200282b8c154dd5002400905320a630533754007001800c00600282b0c148c13cdd50008a60103d87a80008a6103d87a800041346012609c6ea8c044c138dd500098290011828000a09c98010014c0040052225980099b873001300200330010038999119192cc004cdc3980298030009919800800801912cc0040062900044c034cc008008c1600050554566002646600200200444b30010018a518acc004cdc4a40406e34dd7182b800c4cc008008c160006294105220aa8cc00488c966002609660a86ea8006266e24008dd6982c182a9baa0018a50414c60ae60a86ea8c15cc150dd50014dc8a441009b8f4881009182b182b982b982b982b982b982b982b982b800c8c966002608660a66ea80062602a660ac60ae60a86ea80052f5c114c103d87a8000414860ac60a66ea8006460ac60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae00323056305730573057305730573057305730573057305730573057305730573057305730570019182b182b982b982b982b982b982b982b982b982b982b982b982b982b982b982b982b800c8c158c15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15c006460ac60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae0032305630573057305730573057305730573057305730573057001918211b8d0019182b182b982b982b982b982b982b982b982b982b982b800c8c158c15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15cc15c006460ac60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae60ae003230563057305730573057305730573057305730570019182b182b982b982b982b982b982b982b800c8c158c15cc15cc15cc15cc15cc15c006460ac60ae60ae60ae60ae0032232330010010032259800800c530103d87a80008992cc004c01000626030660b26e9ccc164c158004cc164c15c0052f5c097ae0899801801982d80120aa3059001415d30513754095222232330010010052259800800c4cc168cdd81ba9005374c00897adef6c608994c004dd7182c000cdd5982c800cc1740092225980099b9000900389982f19bb037520126e980200162b30013371e012007132598009826182e1baa00189982f99bb0375201460c060ba6ea800400a200482da6002013008801200e89982f19bb037520066e98008cc01801800505a20b4182d800a0b291919800800801112cc004006297adef6c608991982c19bb03055001374c64660020026eacc15c008896600200314bd6f7b63044c8cc16ccdd8182c0009ba83370290001bad305900133003003305d002305b00141646600600660b400460b000282b2444653001001802400d0011112cc00400a200319800801cc17000a6600860b6004002801905948888cc04c0108c966002609660ae6ea800626644b30010028acc004c124c164dd500144c9660020030028992cc004006007003801c00e26644b3001001802c4c966002003006803401a264b300130640038acc004cdd7983198301baa00a598009827982f9baa00b8983198301baa00b880620bc899baf00d0088a50417900741846eb400600c8320c18400505f1bae0013060002418460bc00282e0c168dd500140050584006003001800a0be305b30583754002294229410561809182b9baa001911919800800801912cc0040062980103d87a80008992cc004c01000626030660b26ea40052f5c1133003003305b00241546eb8c1640050572444444444444444444444444453001223259800800c52844ca600260e660e80033259800983018381baa00189bad30713074375660e860e26ea80062900020de30730019bab00248896600200315980099baf00300589824801452820e28992cc004cdd79839800a6010140008acc004cdc49bad30743077375660e800200713375e0086e98c1e000a29410724566002609400713375e00800d14a08391072183b000a0e8194c004006007223307400233074374c00297ae04004444b3001002899800a6103d87a80004bd6f7b63044ca60026eb8c1c80066eacc1cc00660ee0069112cc004c08c00e2b30013022003899802981b9983c1ba60024bd70000c4cc01530103d87a800000641d119800803c006446600e004660f466ec0dd48029ba6004001401c83a060ea004839a2942294229410741ba60029119198008009bac301a306f375400644b30010018a508acc004cdc79bae30730010038a51899801001183a000a0dc41c522329800800c00e00480088896600200510018994c00401260ec00798008014dd71838800cdd59839000c888c966002b30010018a518a5041dd14c0103d87a80008981b9983c1ba60014bd7020e8329800800c00e004800888966002005100189919914c00401a60fe00b32330010010052259800800c4cc1fccdd81ba9004375000697adef6c608994c004dd7183e800cdd6983f000cc208040092225980099b900080038998418099bb037520106ea001c0162b30013371e0100071325980098389840809baa0018998420099bb03752012610a026104026ea800400a2004840008c96600260e200314c0103d87a80008982199842009ba80014bd702100023370000e00513308301337606ea400cdd400119803003000a0fe41fc30800100141f88030dd7183c0009bad3079001307b00241e48059004183a00120e49192cc004c190c1b4dd5000c5200089bad3071306e37540028360c96600260c860da6ea8006298103d87a8000899198008009bab3072306f375400444b30010018a6103d87a8000899192cc004c07c0062b3001301e001898199983a183900125eb82298103d87a800041c1133004004307600341c06eb8c1c0004c1cc00507120d832330010010022259800800c5300103d87a8000899192cc004c0780062b3001301d0018981919839983880125eb82298103d87a800041bd133004004307500341bc6eb8c1bc004c1c80050702444464b3001306000b8acc004c088cc0180808cdc7800821456600266e240f4c008dd5981a18389baa30343071375405f13259800983098389baa0018acc004cc01419cdd7183a98391baa0018acc0056600266ebd300103d87d80000498a518acc004cc0800d80e62604609314a08381070456600266e240f8c00e600330013758605a60e46ea819e0bd0478232010a5eb7bdb182446600c6eacc0dcc1d0dd5001000a0128acc0056600260c260e26ea8112266005300198009bac302d307237540cf05e822cc0cccc1d0c1d4c1c8dd502225eb8100852f5bded8c122330063756606e60e86ea8008005009198021bab303530723754606a60e46ea80c0c0296600260c207d14bd6f7b63044c8c8cc0040052f5bded8c044b300100189983b99bb04c01014000374c00697adef6c608994c004dd7183a800cdd5983b000cc1e80092225980099b904890000389983d99bb04c01014000374c00e00b1598009812801c4cc1eccdd82601014000374c00e00313307b337606ea400cdd300119803003000a0ee41dc307800141d8646600200297adef6c602259800800c4cc1d8cdd8261014000375008097adef6c608994c004dd7183a000cdd6983a800cc1e40092225980099b904890000389983d19bb04c01014000375008800b1598009812001c4cc1e8cdd82601014000375008800313307a337606ea400cdd400119803003000a0ec41d8307700141d483822945070466002602a0794a14a283822c83822c83822c83822c83822c8380c06c1122c837a2c837a33001223259800983518399baa001899b88375a60ee60e86ea800400a2941072183b18399baa303630733754005375e980103d87c80009baf4c0103d879800048896600260d401d13232598009832983a9baa0018acc004cc028dd5981c983b1baa3039307637540686eacc0e4c1d8dd5183a9919bb0307a001307a307b001375860f260ec6ea8006264b3001306630763754003159800998050361bae307a307737540031598009980301d81f45660026604a07609f15980098020274528c566002605009d14a31300504e41d483aa2c83aa2c83aa2c83aa2c83a8c08012e2c83a22c83a0cc0400948c96600266ebcc1e8c1dcdd5000826456600266ebcc0e8c1dcdd5000825c56600266ebcc0c8c1dcdd5000825456600266ebcc0a4c1dcdd5000824c56600266e3cdd71809983b9baa0010488acc004cdc79bae30333077375400208f15980099b8f375c602860ee6ea800411a2b30013371e6eb8c054c1dcdd5000822c56600266e1cdd6980b183b9baa0010438acc004cdc79bae30193077375400208515980099b8f375c603660ee6ea80041062b30013370e6eb4c05cc1dcdd5000820456600266e1cdd6980e183b9baa00103e8acc004cdc39bad30183077375400207f15980099b87375a603a60ee6ea80040f62b30013066375a603c60ee6ea80062b30013371206c6eb4c07cc1dcdd5000c56600266ebcc080c1dcdd5000801c4cdc79bae30223077375400208914a083aa2941075452820ea8a5041d514a083aa2941075452820ea8a5041d514a083aa2941075452820ea8a5041d514a083aa2941075452820ea8a5041d514a083aa2941075183b000acc004c05c0fa298103d87b80008a6103d87c800041c9133225980098340084566002604e6601604a466e3c00411e264b3001306630763754003159800998050361bae307a30773754003159800acc004c01013a29462b3001300304e8a518980102720ea41d5159800acc004c198c1d8dd5025c4cc01e600330013758606460ee6ea81b20c704c981c1983c983d183b9baa04b4bd70201aa5eb7bdb18244660166eacc0f0c1e4dd5001000a01c3756607460ee6ea8c0e8c1dcdd501ac528a0ea8acc005660026604a07607f14a31300204e41d51301a0418b20ea8b20ea8b20ea8b20ea8b20ea302004b8b20e88acc004c1a40422b30019800980c820528528a0e88992cc004c198c1d8dd5000c4c96600260ce60ee6ea80062b30013300b06d375c60f660f06ea80062b30013300c3756607660f06ea8c0ecc1e0dd501b1bab303b3078375460ee6466ec0c1f0004c1f0c1f4004dd6183d983c1baa0028acc004cc0980f01422600c09f1641d91641d91641d91641d860420991641d46602204c464b30013375e60f660f06ea80041362b30013375e607660f06ea80041322b30013375e606660f06ea800412e2b30013375e605460f06ea800412a2b30013371e6eb8c050c1e0dd5000824c56600266e3cdd7181a183c1baa0010488acc004cdc79bae30153078375400208f15980099b8f375c602c60f06ea800411a2b30013370e6eb4c05cc1e0dd5000822456600266e3cdd7180d183c1baa0010438acc004cdc79bae301c3078375400208515980099b87375a603060f06ea80041062b30013370e6eb4c064c1e0dd5000820456600266e1cdd6980e983c1baa00103f8acc004cdc39bad301e3078375400207d15980098339bad301f3078375400315980099b89037375a604060f06ea80062b30013375e98103d87d800030213078375400313371e6eb8c08cc1e0dd5000822c52820ec8a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d860ee0031641d1159800983380844c8ca60026eacc1ec0066eb0c1ecc1f00066eacc1ec00922259800981619808015119b8f00104c8acc0066002603c08b4a14a283ca2b30013302903f0418992cc004cdc4a400400315980099b88480001e62b3001337120f20031598009805029c4cc89660026601c600330013758607260fc6ea81ce0d5053829202830020048acc004cc038c00660026eb0c0e4c1f8dd5039c1aa0a30504050600400d1325980099b8932330010010072259800800c520008981d19801001184280800a10402303207b899b8907c30323303907b23232323300100100a2259800800c528456600264b3001337126e34dd718241842809baa00148200122b30013371e6f20dd71844009842809baa0010068acc00660026eb8c22004c21404dd5000ccc010dd718241842809baa0010079bae30403085013754002b954528c660026eb8c22004c21404dd5000ccc010dd718241842809baa001379000f375c6080610a026ea800572a420c0514a084180a2c841808c21c040062946266004004611002002841009085011119b8a3371466e2922010c846a5369676e61747572653100300300248901400030030012325980099b88001480c2266e2ccdc0241000200200515980099b880014820012266e28cdc524410158009800a51a40050015e4800a2b3001337100029040400444cdc519b8a4890159009800a51a40090015e4800a2c84000908001210002371a0031641f46f20dd9981f998400083519840009ba60043308001374c00c97ae08b20f88b20f8232330010010022259800800c52f5bded8c113298009bae307f0019bab308001001998018019842008012444b30010028b46600200300399190021919800800802112cc00400629344c96600200315980098021bad308701308a010028a4d16421405133225980099b90375c6110020046eb8c220040062b30013006375a6112020051330050053308b01001308d010038b210e028b210e02308a01002308a01001422004611402002843808a600260e60034a14a284100a444c80e1084010c208040050800114c00400697adef6c6091198089bab3042307f375400400280a22c83d22c83d22c83d22c83d0c0b81de2c83ca2c83ca2c83c860f600260ec6ea81ba2b300130660108992cc004cdc482198041bab303a30773754607460ee6ea80d62b30015980098020274528c566002605009d14a315980098028274528c4c00c13907520ea41d5132598009833983b9baa0018992cc004c1a0c1e0dd5000c566002660180dc6eb8c1f0c1e4dd5000c5660026601a6eacc0f0c1e4dd5181e183c9baa0373756607860f26ea8c1e0c8cdd8183e800983e983f0009bac307c307937540051598009981381e81f45660026601007a08314a31598009980401e81fc6600260380874a14a283ba294107720ee8b20ee8b20ee8b20ee8b20ee302204b8b20ec330120272325980099baf307c3079375400209d15980099baf303c3079375400209b15980099baf30343079375400209915980099baf302b3079375400209715980099b8f375c602a60f26ea800412a2b30013371e6eb8c0d4c1e4dd5000824c56600266e3cdd7180b183c9baa0010488acc004cdc79bae30173079375400208f15980099b87375a603060f26ea80041162b30013370e6eb4c064c1e4dd5000821456600266e1cdd6980f183c9baa0010408acc004cdc79bae301b30793754002089159800cc004c070dd7180e983c9baa001a50a5141dd15980099b87375a603460f26ea80041062b30013370e6eb4c07cc1e4dd500081fc56600266e240e0dd69810183c9baa0018acc004c1a0dd69810983c9baa0018acc004cdd79811183c9baa001003899b8f375c604860f26ea800411a2941077452820ee8a5041dd14a083ba2941077452820ee8a5041dd14a083ba2941077452820ee8a5041dd14a083ba2941077452820ee8a5041dd14a083ba2941077452820ee30780018b20ea8b20ea59800acc004c00c13629462604e09a83a2298103d87a80008a6103d87c800041d1132598009833183b1baa0018992cc004c19cc1dcdd5000c566002660160da6eb8c1ecc1e0dd5000c566002660186eacc0ecc1e0dd5181d983c1baa0363756607660f06ea8c1dcc8cdd8183e000983e183e8009bac307b307837540051598009981301e01ec566002600809f14a31598009803027c528c566002600a09f14a31302904f41d883b10764590764590764590764590761810825459075198088131192cc004cdd7983d983c1baa00104d8acc004cdd7981d983c1baa00104c8acc004cdd79819983c1baa00104b8acc004cdd79815183c1baa00104a8acc004cdc79bae30143078375400209315980099b8f375c606860f06ea80041222b30013371e6eb8c054c1e0dd5000823c56600266e3cdd7180b183c1baa0010468acc004cdc39bad30173078375400208915980099b87375a603060f06ea80041062b30013370e6eb4c064c1e0dd5000820456600266e3cdd7180d183c1baa0010438acc004c06cdd7180e183c1baa0018acc004cdc39bad301d3078375400207f15980099b87375a603c60f06ea80040fa2b30013371206e6eb4c07cc1e0dd5000c56600260ce6eb4c080c1e0dd5000c5660026006604260f06ea8006266e3cdd71811983c1baa0010458a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d914a083b22941076452820ec8a5041d914a083b0c1dc00507420e841d083a0dd7a60103d87b8000375e980103d87e800041c841bc4460c264660020026600a006601600444b30010018a40011332298009bae30750029bab3076002912cc004006200713298009bae30780019bad307900199801801983e8012444b300133710004900044c0d4006200283d060f600283c922233001001002183b80099801001183c000a0ea22c82822c8280c8cc004004008896600200314bd7044cc154c03cc14cdd51829982b00099801001182b800a0a832330010013300e3758601860a26ea81188cdd7982a98291baa00100a2259800800c52f5bded8c113305432598009802180298299baa0018992cc004c11cc14cdd5000c4cc88c8cc8966002609060b06ea80162646464646464646464646464646464646464653001306f00198378094c1bc04660de021375c60de01f375c60de01d375c60de01b375c60de019375c60de017375a60de015375c60de013375c60de011375a60de00f375a60de00d375a60de00b375a60de009375a60de007375a60de0049111111111111111112cc004c2080404e26605061020204a26604e02226605002026604e01e264b3001306e0018acc004c1fcdd5009c09e2c84000a2b300130750018acc004c1fcdd5009c09e2c84000a2b300130720018acc004c1fcdd5009c09e2c84000a2b300130710018acc004c1fcdd5009c09e2c84000a2b300130700018acc004c1fcdd5009c09e2c84000a2b3001306f0018acc004c1fcdd5009c09e2c84000a2c83e907d20fa41f483e907d183e9baa0128b20fe1837800983700098368009836000983580098350009834800983400098338009833000983280098320009831800983100098308009830000982f800982f000982c9baa0058b20ae2232598009824800c4c96600260be003133005305e0010038b20b8305a37540071598009828000c56600260b46ea800e00516416d16416082c0c160dd5001099bb000500322598009823982b9baa00289919192cc004c17c00a26600c60bc006264b3001304b0018992cc004c18400626464b3001304e0018992cc004c19000626601660c600201316418460be6ea800a2b3001305500189919194c004dd69832800cdd69832801cdd698328012444b300130690048074590660c194004c190004c17cdd500145905d20ba305d375400260c000316417860b86ea800a2b300130520018acc004c170dd500140162c82ea2c82d105a182d1baa0018b20b8305d001305d0013058375400516415860ae60a86ea800488c966002608c0031323259800982e80140122c82d0dd7182d800982b9baa0038acc004c13400626464b3001305d00280245905a1bae305b0013057375400716415482a8c154dd5001459052180718299baa0018b20a23055001330020023056001414c460a460a660a660a60026ebd30103d87a80008b2098116410064660020026eb0c110c104dd501b112cc004006298103d87a80008992cc004cdd7982318219baa00102f8980219822800a5eb82266006006608e0048208c11400504319b80375a608660806ea80080ecdd2a40011640f46082607c6ea8c004c0f8dd5001181f9820182018201820182018201820181e1baa0312304030410018b20748b2074181580098150009814800981400098138009813000981280098120009811800981100098108009810000980f800980f000980e800980e000980d800980b1baa301930163754003164050602e010602e60300108b2014180580098031baa00c8a4d1365640101"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/boundedLz.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/boundedLz.ts new file mode 100644 index 0000000000..609fa08622 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/boundedLz.ts @@ -0,0 +1,102 @@ +/** + * Decompresses the UCS-2 byte form produced by LZString while enforcing an + * output limit during expansion. The upstream decoder exposes only an + * after-the-fact string result, which permits small decompression bombs. + * + * @param compressed - Big-endian UCS-2 bytes from `compressToUint8Array`. + * @param maxOutputChars - Maximum UTF-16 code units to produce. + * @returns Decoded text, or `null` for malformed or oversized input. + */ +export function decompressLzStringBounded( + compressed: Uint8Array, + maxOutputChars: number, +): string | null { + if ( + compressed.length === 0 || + compressed.length % 2 !== 0 || + !Number.isSafeInteger(maxOutputChars) || + maxOutputChars <= 0 + ) { + return null; + } + + const codeUnitCount = compressed.length / 2; + let codeUnitIndex = 0; + let bitMask = 0x8000; + + const codeUnitAt = (index: number): number => + compressed[index * 2]! * 256 + compressed[index * 2 + 1]!; + + let currentCodeUnit = codeUnitAt(0); + const readBits = (count: number): number | null => { + let value = 0; + let power = 1; + for (let i = 0; i < count; i++) { + if (codeUnitIndex >= codeUnitCount) return null; + if ((currentCodeUnit & bitMask) !== 0) value |= power; + power *= 2; + bitMask >>= 1; + if (bitMask === 0) { + bitMask = 0x8000; + codeUnitIndex++; + if (codeUnitIndex < codeUnitCount) currentCodeUnit = codeUnitAt(codeUnitIndex); + } + } + return value; + }; + + const dictionary: Array = [undefined, undefined, undefined]; + let enlargeIn = 4; + let dictionarySize = 4; + let bitsPerCode = 3; + + const initialKind = readBits(2); + if (initialKind === null || initialKind === 2) return initialKind === 2 ? "" : null; + const initialValue = readBits(initialKind === 0 ? 8 : 16); + if (initialValue === null) return null; + + let previous = String.fromCharCode(initialValue); + dictionary[3] = previous; + let output = previous; + if (output.length > maxOutputChars) return null; + + while (true) { + const encoded = readBits(bitsPerCode); + if (encoded === null) return null; + let code = encoded; + + if (code === 0 || code === 1) { + const literal = readBits(code === 0 ? 8 : 16); + if (literal === null) return null; + dictionary[dictionarySize] = String.fromCharCode(literal); + code = dictionarySize; + dictionarySize++; + enlargeIn--; + } else if (code === 2) { + return output; + } + + if (enlargeIn === 0) { + enlargeIn = 2 ** bitsPerCode; + bitsPerCode++; + } + + let entry = dictionary[code]; + if (entry === undefined) { + if (code !== dictionarySize) return null; + entry = previous + previous.charAt(0); + } + if (output.length + entry.length > maxOutputChars) return null; + output += entry; + + dictionary[dictionarySize] = previous + entry.charAt(0); + dictionarySize++; + enlargeIn--; + previous = entry; + + if (enlargeIn === 0) { + enlargeIn = 2 ** bitsPerCode; + bitsPerCode++; + } + } +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/constants.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/constants.ts index 76d5963119..1ae2998b80 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/masumi/constants.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/constants.ts @@ -1,44 +1,100 @@ -import { Address, EnterpriseAddress, ScriptHash } from "@evolution-sdk/evolution"; - -import { getCardanoNetworkId, normalizeCardanoNetwork } from "../../constants"; - /** * Masumi `PaymentSourceType` targeted by this implementation. `Web3CardanoV2` - * is the `vested_pay` payment-v2 escrow whose datum this scheme builds. + * is the `vested_pay` payment-v2 escrow whose datum this scheme builds. Any + * other value MUST be rejected: the field selects the contract generation and + * is not advisory. */ export const MASUMI_PAYMENT_SOURCE_TYPE = "Web3CardanoV2"; /** - * Script hash of Masumi's canonical `vested_pay` payment-v2 escrow, with the - * deployment parameters applied (`required_admins_multi_sig = 2`, the three - * Masumi admin key hashes, `cooldown_period = 420000`). Network-independent — - * only the address header differs per network. Derived with Masumi's own - * `getPaymentScriptV2` (mesh `1.9.0-beta.102`) and cross-checked by reproducing - * their published V1 address with the same method. + * Global Masumi V2 registry policy id. A non-empty `terms.agentIdentifier` + * makes a registry claim and MUST start with this policy — another policy is + * not a Masumi V2 registry. + */ +export const MASUMI_REGISTRY_POLICY_ID = "67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b"; + +/** + * Non-zero `collateral_return_lovelace` floor. The client computes the + * collateral itself, but a positive value below this floor is rejected by + * Masumi's off-chain validation (`CONSTANTS.MIN_COLLATERAL_LOVELACE`). + */ +export const MASUMI_MIN_COLLATERAL_LOVELACE = 1_435_230n; + +/** Minimum gap from `pay_by_time` to `submit_result_time`. */ +export const MASUMI_MIN_PAY_TO_SUBMIT_MS = 5n * 60n * 1000n; + +/** Minimum gap from `submit_result_time` to `unlock_time`. */ +export const MASUMI_MIN_SUBMIT_TO_UNLOCK_MS = 15n * 60n * 1000n; + +/** Minimum gap from `unlock_time` to `external_dispute_unlock_time`. */ +export const MASUMI_MIN_UNLOCK_TO_DISPUTE_MS = 15n * 60n * 1000n; + +/** + * Minimum lead time from issuance to `submit_result_time`. Masumi's own + * purchase and payment endpoints refuse a `submitResultTime` closer than this, + * so an issuer that undercuts it mints a 402 Masumi tooling will not register. + * Only the issuer can check it — by the time a facilitator sees the payment the + * clock has already moved, and re-checking it there would reject a lock that was + * legitimate when it was built. + */ +export const MASUMI_MIN_SUBMIT_RESULT_LEAD_MS = 15n * 60n * 1000n; + +/** + * How far past issuance the last escrow deadline may sit. + * + * The minimum gaps stop a seller from making the window uselessly short; this + * stops the opposite attack. `vested_pay` gates the buyer's `WithdrawRefund` on + * `must_start_after(validity_range, submit_result_time)`, so until that deadline + * passes the buyer cannot recover the payment **or** its collateral. Without a + * ceiling a hostile 402 can name a deadline years out and freeze the buyer's + * funds for that long while passing every other check. * - * This is really just a fallback: the authoritative escrow address comes from - * the purchase via the required `extra.contractAddress` (which verify() checks - * against `payTo`). It only backs the {@link masumiContractAddress} helper as a - * convenience for the canonical deployment, and must not be treated as - * authoritative — a different (e.g. self-hosted) deployment has a different - * address, and this hash goes stale if Masumi ever redeploys. + * 30 days is far beyond any legitimate agent settlement — Masumi's own defaults + * are `submitResultTime + 6h` and `+ 12h` — and is overridable per call. */ -const MASUMI_ESCROW_SCRIPT_HASH = "2d6abca32e4b22b59e948ef22dfe682017de917a9ec088aa1bc3c64e"; +export const MASUMI_MAX_DEADLINE_HORIZON_MS = 30n * 24n * 60n * 60n * 1000n; /** - * Default `collateral_return_lovelace` when the requirements omit it (the - * contract itself defaults this field to 0). All other datum identifiers and - * time bounds are required from the requirements — they are purchase-bound and - * must not be defaulted or randomized. + * Default ceiling on `collateral_return_lovelace` a client will lock. + * + * The collateral is derived from the datum size, and the datum carries the + * seller's `reference_key` and `reference_signature` verbatim — each allowed up + * to `MAX_MASUMI_COSE_BYTES`. A seller who pads them inflates the min-UTXO the + * escrow must clear and therefore the buyer's own locked funds. A realistic lock + * is a ~450-byte datum needing at most ~3.7 ADA of collateral; padding toward + * the ledger's transaction limit pushes that above 50 ADA. + * + * The ceiling sits well above legitimate use and far below the attack range. */ -export const MASUMI_DEFAULT_COLLATERAL_LOVELACE = 0n; +export const MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE = 15_000_000n; /** - * Non-zero `collateral_return_lovelace` floor enforced by Masumi's off-chain - * validation (`checkPaymentAmountsMatch`, `CONSTANTS.MIN_COLLATERAL_LOVELACE`). - * A positive collateral below this is rejected. + * Whether the four escrow deadlines are ordered and clear the minimum gaps. + * + * This is the single copy of the rule: the issuer applies it to what it is about + * to sign, the client to the seller-signed `terms`, and the facilitator to the + * integers actually in the datum. They must not drift — a gap one side accepts + * and another rejects is a 402 that can never be paid, and the values are inside + * `termsDigest` so it cannot be repaired after issuance. + * + * @param payByTime - Datum `pay_by_time`. + * @param submitResultTime - Datum `submit_result_time`. + * @param unlockTime - Datum `unlock_time`. + * @param externalDisputeUnlockTime - Datum `external_dispute_unlock_time`. + * @returns True when every interval clears its minimum. */ -export const MASUMI_MIN_COLLATERAL_LOVELACE = 1_435_230n; +export function masumiDeadlineIntervalsHold( + payByTime: bigint, + submitResultTime: bigint, + unlockTime: bigint, + externalDisputeUnlockTime: bigint, +): boolean { + return ( + payByTime + MASUMI_MIN_PAY_TO_SUBMIT_MS <= submitResultTime && + submitResultTime + MASUMI_MIN_SUBMIT_TO_UNLOCK_MS <= unlockTime && + unlockTime + MASUMI_MIN_UNLOCK_TO_DISPUTE_MS <= externalDisputeUnlockTime + ); +} // Min-UTXO for the escrow output must cover the datum as it will look AFTER the // seller submits a result, not at lock time: `result_hash` grows from empty to @@ -86,18 +142,32 @@ export function masumiMinUtxoLovelace( } /** - * Resolves the address of Masumi's canonical `vested_pay` escrow for a network. - * Fallback convenience only — the authoritative escrow address comes from the - * purchase via `extra.contractAddress`; a server on a different deployment - * supplies its own and must not rely on this. + * The `collateral_return_lovelace` a lock must carry. + * + * The seller never supplies or signs this value: the client computes it from + * the requested asset and live protocol parameters, and the escrow output must + * satisfy `lockedLovelace = requestedLovelace + collateral_return_lovelace`. + * + * A **lovelace** payment can therefore run with zero collateral when the + * requested amount already clears the post-`SubmitResult` min-UTXO. A + * **native-token** payment has `requestedLovelace = 0`, so zero collateral + * cannot satisfy both rules and the collateral must be at least the larger of + * the floor and that min-UTXO. * - * @param network - The x402 Cardano network identifier (or a CIP-34 alias). - * @returns The bech32 enterprise script address of Masumi's canonical escrow. + * @param requestedLovelace - `amount` for a lovelace payment, `0` for a token. + * @param lockDatumBytes - Byte length of the current (empty-result) lock datum. + * @param nativeTokenCount - Distinct native tokens carried by the escrow output. + * @param coinsPerUtxoByte - Live `coinsPerUtxoByte` protocol parameter. + * @returns The collateral to place in the datum. */ -export function masumiContractAddress(network: string): string { - const enterprise = new EnterpriseAddress.EnterpriseAddress({ - networkId: getCardanoNetworkId(normalizeCardanoNetwork(network)), - paymentCredential: ScriptHash.fromHex(MASUMI_ESCROW_SCRIPT_HASH), - }); - return Address.toBech32(enterprise as unknown as Address.Address); +export function masumiCollateralLovelace( + requestedLovelace: bigint, + lockDatumBytes: number, + nativeTokenCount: number, + coinsPerUtxoByte: bigint, +): bigint { + const minUtxo = masumiMinUtxoLovelace(lockDatumBytes, nativeTokenCount, coinsPerUtxoByte); + if (requestedLovelace >= minUtxo) return 0n; + const shortfall = minUtxo - requestedLovelace; + return shortfall > MASUMI_MIN_COLLATERAL_LOVELACE ? shortfall : MASUMI_MIN_COLLATERAL_LOVELACE; } diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/cose.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/cose.ts new file mode 100644 index 0000000000..b2075f7cf9 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/cose.ts @@ -0,0 +1,136 @@ +import { Address, CBOR, COSE, Credential } from "@evolution-sdk/evolution"; + +/** + * Verification of the seller's CIP-8 authorization over `termsDigest`. + * + * The seller calls CIP-30 `signData(sellerAddress, lowercaseHex(termsDigest))`; + * `extra.referenceKey` carries the complete CBOR `COSE_Key` and + * `extra.referenceSignature` the complete CBOR `COSE_Sign1`. The attached + * payload is the 32-byte digest, `hashed` is `false`, and the external AAD is + * empty. + * + * The address binding — `Blake2b-224(publicKey)` equal to the seller's + * payment-key credential — is what ties the signature to the *address* rather + * than to an arbitrary key, and is never skipped. + */ + +/** COSE_Key parameter labels (RFC 8152 §7.1 and §13.2). */ +const KEY_LABEL_KTY = 1n; +const KEY_LABEL_KID = 2n; +const KEY_LABEL_ALG = 3n; +const KEY_LABEL_CRV = -1n; +const KEY_LABEL_X = -2n; +const KEY_LABEL_D = -4n; + +/** `kty = OKP`, `alg = EdDSA`, `crv = Ed25519`. */ +const KTY_OKP = BigInt(COSE.Label.KeyType.OKP); +const ALG_EDDSA = BigInt(COSE.Label.AlgorithmId.EdDSA); +const CRV_ED25519 = BigInt(COSE.Label.CurveType.Ed25519); + +/** Ed25519 public keys are exactly 32 bytes. */ +const ED25519_PUBLIC_KEY_BYTES = 32; + +/** + * Reads a text-labelled COSE header. `HeaderMap.header()` looks up by + * reference, so labels are matched structurally instead. + * + * @param headers - The COSE header map. + * @param label - The text label to look for. + * @returns The header value, or `undefined` when absent. + */ +function textHeader(headers: COSE.Header.HeaderMap, label: string): unknown { + for (const [key, value] of headers.headers.entries()) { + if (key.kind === COSE.Label.LabelKind.Text && key.value === label) return value; + } + return undefined; +} + +/** + * Whether two byte arrays are equal. + * + * @param a - The first array. + * @param b - The second array. + * @returns True when both have the same length and contents. + */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + return a.length === b.length && a.every((byte, i) => byte === b[i]); +} + +/** + * Structurally validates the `COSE_Key`: `kty = OKP`, `alg = EdDSA`, + * `crv = Ed25519`, a 32-byte public key, and no private material. + * + * @param keyBytes - The complete CBOR `COSE_Key`. + * @returns The `kid`, or `undefined` when absent; `null` when the key is invalid. + */ +function validateCoseKey(keyBytes: Uint8Array): { kid?: Uint8Array } | null { + const decoded = CBOR.internalDecodeSync(keyBytes); + if (!(decoded instanceof Map)) return null; + const map = decoded as Map; + if (map.get(KEY_LABEL_KTY) !== KTY_OKP) return null; + if (map.get(KEY_LABEL_ALG) !== ALG_EDDSA) return null; + if (map.get(KEY_LABEL_CRV) !== CRV_ED25519) return null; + // A reference key is a public key; private material must never travel in it. + if (map.has(KEY_LABEL_D)) return null; + const publicKey = map.get(KEY_LABEL_X); + if (!(publicKey instanceof Uint8Array) || publicKey.length !== ED25519_PUBLIC_KEY_BYTES) { + return null; + } + const kid = map.get(KEY_LABEL_KID); + return kid instanceof Uint8Array ? { kid } : {}; +} + +/** + * Verifies the seller's COSE authorization over `termsDigest`. + * + * Total: any structural, policy or cryptographic failure returns `false` rather + * than throwing, so the caller can turn it into a single rejection reason. + * + * @param referenceKeyHex - Complete CBOR `COSE_Key` as hex (`extra.referenceKey`). + * @param referenceSignatureHex - Complete CBOR `COSE_Sign1` as hex. + * @param sellerAddressBech32 - The seller address the signature must bind to. + * @param termsDigestHex - The 32-byte `termsDigest` as lowercase hex. + * @returns True when every check in the spec's seller-signed-terms list passes. + */ +export function verifySellerTermsSignature( + referenceKeyHex: string, + referenceSignatureHex: string, + sellerAddressBech32: string, + termsDigestHex: string, +): boolean { + try { + const keyBytes = Uint8Array.from(Buffer.from(referenceKeyHex, "hex")); + const signatureBytes = Uint8Array.from(Buffer.from(referenceSignatureHex, "hex")); + + const key = validateCoseKey(keyBytes); + if (!key) return false; + + const coseSign1 = COSE.COSESign1.coseSign1FromCBORBytes(signatureBytes); + // `hashed` MUST be present and false: with a hashed payload a signature over + // the 28-byte blake2b digest would stand in for one over `termsDigest`. + if (textHeader(coseSign1.headers.unprotected, "hashed") !== false) return false; + + // Equal `kid` values when both are present. + const signatureKid = coseSign1.headers.protected.keyId(); + if (key.kid !== undefined && signatureKid !== undefined && !bytesEqual(key.kid, signatureKid)) { + return false; + } + + // A script payment credential can never be proven by a single Ed25519 key. + const addressHex = Address.toHex(Address.fromBech32(sellerAddressBech32)); + const credential = Address.getPaymentCredential(addressHex); + if (credential === undefined || credential._tag !== "KeyHash") return false; + + // Covers the attached payload, the protected `address` and `alg` headers, + // the Blake2b-224 address binding and the Ed25519 `Sig_structure`, verified + // with an empty external AAD. + return COSE.SignData.verifyData( + addressHex, + Credential.toHex(credential), + Uint8Array.from(Buffer.from(termsDigestHex, "hex")), + { signature: signatureBytes, key: keyBytes }, + ); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/datum.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/datum.ts index 7a201fc031..ef50aaff95 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/masumi/datum.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/datum.ts @@ -1,4 +1,4 @@ -import { Address, Data, InlineDatum, KeyHash, ScriptHash } from "@evolution-sdk/evolution"; +import { AddressEras, Data, InlineDatum, KeyHash, ScriptHash } from "@evolution-sdk/evolution"; /** * Codec for the Masumi `vested_pay` escrow lock datum (payment-v2 / @@ -18,6 +18,14 @@ export interface MasumiCredential { export interface MasumiAddressCredentials { payment: MasumiCredential; stake?: MasumiCredential; + pointer?: MasumiPointer; +} + +/** Legacy pointer stake reference carried by a Cardano pointer address. */ +export interface MasumiPointer { + slot: bigint; + txIndex: bigint; + certIndex: bigint; } /** Typed inputs required to build a fresh lock datum. */ @@ -87,8 +95,16 @@ function credentialToData(cred: MasumiCredential): Data.Data { function addressToData(bech32: string): Data.Data { const creds = addressCredentials(bech32); const stakeOption = creds.stake - ? Data.constr(0n, [Data.constr(0n, [credentialToData(creds.stake)])]) // Some(Inline(cred)) - : Data.constr(1n, []); // None + ? Data.constr(0n, [Data.constr(0n, [credentialToData(creds.stake)])]) + : creds.pointer + ? Data.constr(0n, [ + Data.constr(1n, [ + Data.int(creds.pointer.slot), + Data.int(creds.pointer.txIndex), + Data.int(creds.pointer.certIndex), + ]), + ]) + : Data.constr(1n, []); return Data.constr(0n, [credentialToData(creds.payment), stakeOption]); } @@ -121,24 +137,38 @@ function credentialHex(cred: { _tag: string }): string { * @returns Its payment credential and, for base addresses, its stake credential. */ export function addressCredentials(bech32: string): MasumiAddressCredentials { - const parsed = Address.fromBech32(bech32) as { - paymentCredential: { _tag: string }; - stakingCredential?: { _tag: string }; - }; + const parsed = AddressEras.fromBech32(bech32); + if ( + parsed._tag !== "BaseAddress" && + parsed._tag !== "EnterpriseAddress" && + parsed._tag !== "PointerAddress" + ) { + throw new Error("Masumi datum address must have a payment credential"); + } const payment = { isScript: parsed.paymentCredential._tag === "ScriptHash", hash: credentialHex(parsed.paymentCredential), }; - if (!parsed.stakingCredential) { - return { payment }; + if (parsed._tag === "BaseAddress") { + return { + payment, + stake: { + isScript: parsed.stakeCredential._tag === "ScriptHash", + hash: credentialHex(parsed.stakeCredential), + }, + }; } - return { - payment, - stake: { - isScript: parsed.stakingCredential._tag === "ScriptHash", - hash: credentialHex(parsed.stakingCredential), - }, - }; + if (parsed._tag === "PointerAddress") { + return { + payment, + pointer: { + slot: BigInt(parsed.pointer.slot), + txIndex: BigInt(parsed.pointer.txIndex), + certIndex: BigInt(parsed.pointer.certIndex), + }, + }; + } + return { payment }; } /** @@ -215,9 +245,17 @@ function asHex(d: Data.Data): string | null { : null; } +/** Cardano payment/stake credential hashes are Blake2b-224: 28 bytes. */ +const CREDENTIAL_HASH_HEX_LENGTH = 56; + /** * Decodes a Plutus credential (`Constr 0|1 [hash]`) into a typed credential. * + * The hash length is enforced: `vested_pay` decodes the datum with a typed + * `expect`, so a credential of any other size makes every later spend path fail + * and permanently strands the escrow — after this facilitator has already + * accepted the lock. + * * @param d - The credential Plutus data. * @returns The credential, or `null` on a structural mismatch. */ @@ -225,7 +263,7 @@ function dataToCredential(d: Data.Data): MasumiCredential | null { const c = asConstr(d); if (!c || (c.index !== 0n && c.index !== 1n) || c.fields.length !== 1) return null; const hash = asHex(c.fields[0]); - if (hash === null) return null; + if (hash === null || hash.length !== CREDENTIAL_HASH_HEX_LENGTH) return null; return { isScript: c.index === 1n, hash }; } @@ -242,13 +280,31 @@ function dataToAddress(d: Data.Data): MasumiAddressCredentials | null { if (!payment) return null; const opt = asConstr(c.fields[1]); if (!opt) return null; - if (opt.index === 1n) return { payment }; // None + if (opt.index === 1n) return opt.fields.length === 0 ? { payment } : null; // None if (opt.index !== 0n || opt.fields.length !== 1) return null; - const inline = asConstr(opt.fields[0]); // Inline(cred) - if (!inline || inline.index !== 0n || inline.fields.length !== 1) return null; - const stake = dataToCredential(inline.fields[0]); - if (!stake) return null; - return { payment, stake }; + const stakeRef = asConstr(opt.fields[0]); + if (!stakeRef) return null; + if (stakeRef.index === 0n && stakeRef.fields.length === 1) { + const stake = dataToCredential(stakeRef.fields[0]); + return stake ? { payment, stake } : null; + } + if (stakeRef.index === 1n && stakeRef.fields.length === 3) { + const slot = asInt(stakeRef.fields[0]); + const txIndex = asInt(stakeRef.fields[1]); + const certIndex = asInt(stakeRef.fields[2]); + if ( + slot === null || + txIndex === null || + certIndex === null || + slot < 0n || + txIndex < 0n || + certIndex < 0n + ) { + return null; + } + return { payment, pointer: { slot, txIndex, certIndex } }; + } + return null; } /** @@ -303,7 +359,11 @@ export function parseMasumiLockDatum(datum: Data.Data | string): MasumiDatumView const externalDisputeUnlockTime = asInt(f[15]); const sellerCooldownTime = asInt(f[16]); const buyerCooldownTime = asInt(f[17]); + // The state constructor carries no fields; `FundsLocked` is `Constr 0 []`. + // Accepting `Constr 0 [junk]` would let through a datum the validator's typed + // decode rejects on every later spend, stranding the escrow. const stateConstr = asConstr(f[18]); + if (stateConstr !== null && stateConstr.fields.length !== 0) return null; if ( !buyer || diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/digests.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/digests.ts new file mode 100644 index 0000000000..7906aa84ce --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/digests.ts @@ -0,0 +1,183 @@ +import { sha256 } from "@noble/hashes/sha2.js"; +import type { PaymentRequirements } from "@x402/core/types"; + +import type { CardanoExtraMasumi, MasumiInputCommitment, MasumiTerms } from "../../types"; +import { jcs } from "./jcs"; + +/** + * Domain separator prefixing the canonical commitment manifest. + */ +const INPUT_HASH_DOMAIN = "masumi:x402:input:v1\n"; + +/** + * Domain separator prefixing the canonical signed terms. + */ +const TERMS_DIGEST_DOMAIN = "masumi:x402:terms:v1\n"; + +const encoder = new TextEncoder(); + +/** + * `SHA-256(UTF-8(domain) || UTF-8(JCS(value)))` as lowercase hex — the shape + * both Masumi digests share. + * + * @param domain - The domain-separation prefix, including its trailing newline. + * @param value - The JSON value to canonicalize. + * @returns The lowercase hex digest. + */ +function domainDigest(domain: string, value: unknown): string { + const body = encoder.encode(jcs(value)); + const prefix = encoder.encode(domain); + const buffer = new Uint8Array(prefix.length + body.length); + buffer.set(prefix, 0); + buffer.set(body, prefix.length); + return toHex(sha256(buffer)); +} + +/** + * Lowercase hex of a byte array. + * + * @param bytes - The bytes to encode. + * @returns The lowercase hex string. + */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Decodes an unpadded base64url string into bytes. + * + * @param value - The unpadded base64url text. + * @returns The decoded bytes. + * @throws When the text is not valid base64url. + */ +function base64UrlDecode(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]*$/.test(value)) { + throw new Error("Commitment raw content is not unpadded base64url"); + } + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const decoded = Buffer.from(base64, "base64"); + if (decoded.toString("base64url") !== value) { + throw new Error("Commitment raw content is not canonical unpadded base64url"); + } + return Uint8Array.from(decoded); +} + +/** + * Serializes one commitment part to the bytes its `digest` covers: + * `UTF-8(RFC8785-JCS(content))` for `jcs`, `base64url-decode(content)` for `raw`. + * + * @param part - The commitment part, which MUST carry `content`. + * @param part.canonicalization - How `content` becomes bytes. + * @param part.content - The content to serialize. + * @returns The part bytes. + * @throws When `content` is absent or malformed for the declared canonicalization. + */ +export function commitmentPartBytes(part: { + canonicalization: "jcs" | "raw"; + content?: unknown; +}): Uint8Array { + if (part.content === undefined) { + throw new Error("Commitment part carries no content to digest"); + } + if (part.canonicalization === "raw") { + if (typeof part.content !== "string") { + throw new Error("Commitment raw content must be an unpadded base64url string"); + } + return base64UrlDecode(part.content); + } + return encoder.encode(jcs(part.content)); +} + +/** + * Lowercase hex SHA-256 of a commitment part's bytes. + * + * @param part - The commitment part, which MUST carry `content`. + * @param part.canonicalization - How `content` becomes bytes. + * @param part.content - The content to serialize. + * @returns The lowercase hex digest. + */ +export function commitmentPartDigest(part: { + canonicalization: "jcs" | "raw"; + content?: unknown; +}): string { + return toHex(sha256(commitmentPartBytes(part))); +} + +/** + * Recomputes `inputCommitment.digest` from the commitment's **manifest** — the + * commitment with every part's `content` and the top-level `digest` omitted + * (not nulled). Because the manifest excludes `content` by construction, a part + * whose content the issuer left off the wire does not change the result. + * + * @param commitment - The declared input commitment. + * @returns The lowercase hex `inputHash`. + */ +export function computeInputHash(commitment: MasumiInputCommitment): string { + const manifest = { + version: commitment.version, + algorithm: commitment.algorithm, + parts: commitment.parts.map(part => ({ + name: part.name, + canonicalization: part.canonicalization, + mediaType: part.mediaType, + digest: part.digest, + })), + }; + return domainDigest(INPUT_HASH_DOMAIN, manifest); +} + +/** + * The seller-signed terms object: `terms` plus the seven fields projected from + * the top-level `PaymentRequirements`. + * + * This member list is normative for this scheme version — `termsDigest` is only + * reproducible when both sides agree on it exactly, so changing it is a + * breaking change to the scheme rather than an implementation detail. + */ +export type MasumiSignedTerms = MasumiTerms & { + scheme: string; + assetTransferMethod: string; + network: string; + contractAddress: string; + amount: string; + asset: string; + maxTimeoutSeconds: number; +}; + +/** + * Reconstructs `signedTerms` from the terms and the requirements they were + * issued against. + * + * `terms` is spread verbatim: an `agentIdentifier` that is absent, `null` or + * empty selects the same identity mode but stays a different signed wire value, + * so it must not be omitted, inserted or replaced here. + * + * @param extra - The masumi `extra` block. + * @param requirements - The canonical payment requirements. + * @returns The signed terms object. + */ +export function buildSignedTerms( + extra: CardanoExtraMasumi, + requirements: PaymentRequirements, +): MasumiSignedTerms { + return { + ...extra.terms, + scheme: requirements.scheme, + assetTransferMethod: extra.assetTransferMethod, + network: requirements.network, + contractAddress: requirements.payTo, + amount: requirements.amount, + asset: requirements.asset, + maxTimeoutSeconds: requirements.maxTimeoutSeconds, + }; +} + +/** + * Computes the `termsDigest` the seller authorizes with CIP-30 `signData`. + * + * @param signedTerms - The reconstructed signed terms. + * @returns The lowercase hex digest. + */ +export function computeTermsDigest(signedTerms: MasumiSignedTerms): string { + return domainDigest(TERMS_DIGEST_DOMAIN, signedTerms); +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/identifier.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/identifier.ts new file mode 100644 index 0000000000..82c7ce8264 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/identifier.ts @@ -0,0 +1,113 @@ +import LZString from "lz-string"; + +import { + MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES, + MAX_MASUMI_IDENTIFIER_TEXT_CHARS, +} from "../../limits"; +import { decompressLzStringBounded } from "./boundedLz"; + +/** + * The Masumi compatibility identifier — a lookup key that lets Masumi tooling + * locate an x402 payment. + * + * ```text + * identifierText = + * (sellerNonceHex + agentIdentifierHex) + "." + + * buyerNonceHex + "." + referenceSignatureHex + "." + + * referenceKeyHex + "." + contractAddressBech32 + * + * blockchainIdentifier = hex(LZString.compressToUint8Array(identifierText)) + * ``` + * + * The encoded *text* values are joined before compression; the first four + * segments are never hex-decoded first. Segment two may be empty and the empty + * segment is preserved. + */ + +/** `sellerNonce` occupies the first 64 characters of segment one. */ +const SELLER_NONCE_HEX_LENGTH = 64; + +/** The five period-delimited segments of a decoded identifier. */ +export interface MasumiIdentifierParts { + sellerNonce: string; + /** Empty when the seller is unregistered. */ + agentIdentifier: string; + buyerNonce: string; + referenceSignature: string; + referenceKey: string; + contractAddress: string; +} + +/** + * Builds the exact `identifierText` for a payment. + * + * @param parts - The identifier segments. + * @returns The period-delimited ASCII text. + */ +export function buildIdentifierText(parts: MasumiIdentifierParts): string { + return [ + parts.sellerNonce + parts.agentIdentifier, + parts.buyerNonce, + parts.referenceSignature, + parts.referenceKey, + parts.contractAddress, + ].join("."); +} + +/** + * Encodes an identifier as the wire `blockchainIdentifier`. + * + * @param parts - The identifier segments. + * @returns Lowercase hex of the LZString-compressed identifier text. + */ +export function encodeBlockchainIdentifier(parts: MasumiIdentifierParts): string { + const text = buildIdentifierText(parts); + if (text.length > MAX_MASUMI_IDENTIFIER_TEXT_CHARS) { + throw new Error("Masumi identifier text exceeds the character limit"); + } + const compressed = LZString.compressToUint8Array(text); + if (compressed.length > MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES) { + throw new Error("Masumi identifier exceeds the compressed byte limit"); + } + return Buffer.from(compressed).toString("hex").toLowerCase(); +} + +/** + * Decodes a wire `blockchainIdentifier` back into its five segments. + * + * Total: returns `null` when the value is not hex, does not decompress, or does + * not carry exactly five segments with a 64-character seller nonce. + * + * @param blockchainIdentifier - Lowercase hex of the compressed identifier. + * @returns The decoded segments, or `null`. + */ +export function decodeBlockchainIdentifier( + blockchainIdentifier: string, +): MasumiIdentifierParts | null { + if ( + blockchainIdentifier.length === 0 || + blockchainIdentifier.length % 2 !== 0 || + blockchainIdentifier.length / 2 > MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES + ) { + return null; + } + if (!/^[0-9a-f]+$/.test(blockchainIdentifier)) return null; + const text = decompressLzStringBounded( + Uint8Array.from(Buffer.from(blockchainIdentifier, "hex")), + MAX_MASUMI_IDENTIFIER_TEXT_CHARS, + ); + if (!text || text.length > MAX_MASUMI_IDENTIFIER_TEXT_CHARS) return null; + const segments = text.split("."); + if (segments.length !== 5) return null; + const [sellerIdentifier, buyerNonce, referenceSignature, referenceKey, contractAddress] = + segments; + if (sellerIdentifier.length < SELLER_NONCE_HEX_LENGTH) return null; + return { + sellerNonce: sellerIdentifier.slice(0, SELLER_NONCE_HEX_LENGTH), + agentIdentifier: sellerIdentifier.slice(SELLER_NONCE_HEX_LENGTH), + buyerNonce, + referenceSignature, + referenceKey, + contractAddress, + }; +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/issue.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/issue.ts new file mode 100644 index 0000000000..d6383f6b5d --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/issue.ts @@ -0,0 +1,441 @@ +import { Address, COSE, PrivateKey } from "@evolution-sdk/evolution"; +import { addressFromSeed, keysFromSeed } from "@evolution-sdk/evolution/sdk/wallet/Derivation"; +import type { PaymentRequirements } from "@x402/core/types"; + +import { + ASSET_TRANSFER_METHOD_MASUMI, + CANONICAL_CARDANO_ASSET_REGEX, + getCardanoNetworkId, + POSITIVE_CANONICAL_AMOUNT_REGEX, +} from "../../constants"; +import type { + CardanoExtraMasumi, + MasumiCommitmentPart, + MasumiDeployment, + MasumiInputCommitment, + MasumiTerms, +} from "../../types"; +import { masumiEscrowAddress, resolveMasumiDeployment } from "./blueprint"; +import { + MASUMI_MAX_DEADLINE_HORIZON_MS, + MASUMI_MIN_SUBMIT_RESULT_LEAD_MS, + MASUMI_PAYMENT_SOURCE_TYPE, + masumiDeadlineIntervalsHold, +} from "./constants"; +import { + buildSignedTerms, + commitmentPartDigest, + computeInputHash, + computeTermsDigest, +} from "./digests"; +import { encodeBlockchainIdentifier } from "./identifier"; +import { isPosixMsString, validateMasumiExtra } from "./schema"; + +/** + * The requirements-issuer side of the Masumi method: builds a + * `PaymentRequirements` whose `extra` carries a consistent request commitment, + * seller-signed `terms`, CIP-8 authorization and compatibility identifier. + * + * The issuer MUST store the complete object and reuse it verbatim on the paid + * retry — regenerating the nonce, deadlines, commitment or policies produces a + * different `termsDigest` and invalidates the payment the buyer built. + */ + +/** The seller's CIP-30 `signData` result, as the wire carries it. */ +export interface MasumiSellerAuthorization { + /** Complete CBOR `COSE_Key` as lowercase hex. */ + key: string; + /** Complete CBOR `COSE_Sign1` as lowercase hex. */ + signature: string; +} + +/** + * Signs `termsDigest` on the seller's behalf. Wrap a CIP-30 wallet's + * `signData(sellerAddress, lowercaseHex(termsDigest))` here, or use + * {@link toMasumiSellerSigner} for a mnemonic-backed seller. + */ +export type MasumiTermsSigner = ( + sellerAddress: string, + termsDigestHex: string, +) => MasumiSellerAuthorization | Promise; + +/** One part of the request the issuer commits to. */ +export interface MasumiCommitmentInput { + /** Unique non-empty part name (conventionally `parameters`, `body`, `raw`). */ + name: string; + /** How `content` becomes bytes. */ + canonicalization: "jcs" | "raw"; + /** Optional media type, preserved byte-for-byte. */ + mediaType?: string; + /** RFC 8785-compatible JSON for `jcs`, unpadded base64url for `raw`. */ + content: unknown; + /** + * Whether to echo `content` on the wire. Set `false` for parts derived from + * the client's own request bytes, which it recomputes from what it sent — + * this keeps a large body out of the `PAYMENT-REQUIRED` header without + * weakening the commitment, because the manifest excludes `content` anyway. + * Defaults to `true`, which is REQUIRED for issuer-originated content. + */ + echoContent?: boolean; +} + +/** Everything needed to issue one Masumi 402. */ +export interface IssueMasumiRequirementsInput { + /** The x402 Cardano network identifier. */ + network: string; + /** `lovelace` or a canonical `policyId.assetNameHex` unit. */ + asset: string; + /** Positive canonical decimal string in the asset's smallest unit. */ + amount: string; + maxTimeoutSeconds: number; + /** The seller's key-credential address on the selected network. */ + sellerAddress: string; + /** Produces the seller's CIP-8 authorization over `termsDigest`. */ + signTerms: MasumiTermsSigner; + /** The ordered request commitment parts. */ + commitment: MasumiCommitmentInput[]; + /** POSIX-millisecond deadlines, ordered and clearing the spec's minimums. */ + payByTime: string; + submitResultTime: string; + unlockTime: string; + externalDisputeUnlockTime: string; + /** + * 32 fresh cryptographically random bytes as 64 lowercase hex characters. A + * fresh nonce MUST be generated for every new requirements object; one is + * generated here when omitted. + */ + sellerNonce?: string; + /** Empty, or the buyer nonce extracted from the protected request. */ + buyerNonce?: string; + /** Registry asset identifier; omitted, `null` or empty means unregistered. */ + agentIdentifier?: string | null; + /** Optional key-credential payout address for the seller. */ + sellerReturnAddress?: string; + settlementPolicy?: MasumiTerms["settlementPolicy"]; + submissionPolicy?: CardanoExtraMasumi["submissionPolicy"]; + confirmationPolicy?: CardanoExtraMasumi["confirmationPolicy"]; + /** Non-canonical validator parameters. Required on Preview. */ + deployment?: MasumiDeployment; + /** + * Test-only escape hatch that skips {@link assertMasumiIssuePolicy}. It exists + * so negative fixtures can mint the hostile 402s a client and facilitator MUST + * refuse. Never set it in production: the deadlines it lets through are the + * exact ones the buyer rejects before it signs, and because they are covered + * by `termsDigest` the resulting 402 cannot be repaired — only re-issued. + */ + unsafeSkipPolicyChecks?: boolean; + /** + * How far past now `externalDisputeUnlockTime` may sit, defaulting to + * {@link MASUMI_MAX_DEADLINE_HORIZON_MS}. This is the seller's own ceiling; a + * buyer applies its own independently, so raising it here does not oblige + * anyone to accept the result. + */ + maxDeadlineHorizonMs?: bigint; +} + +/** The four escrow deadlines, in the order the scheme requires them. */ +const DEADLINE_FIELDS = [ + "payByTime", + "submitResultTime", + "unlockTime", + "externalDisputeUnlockTime", +] as const; + +/** + * Rejects a Masumi 402 the buyer would refuse anyway, at the only moment the + * seller can still fix it. + * + * Every value checked here is covered by `termsDigest`, so a 402 that fails a + * buyer-side or facilitator-side rule cannot be patched afterwards — the whole + * requirements object has to be re-issued and re-signed. Failing at issue time + * turns a silent "no client will ever pay this" into an immediate error. + * + * @param input - The requirements about to be issued. + * @param nowMs - Current POSIX time in milliseconds. + * @throws When a deadline or the payment window would make the 402 unpayable. + */ +function assertMasumiIssuePolicy(input: IssueMasumiRequirementsInput, nowMs: bigint): void { + // Guard before converting: `BigInt("")` and `BigInt("12x")` throw a raw + // SyntaxError, which would surface as a crash instead of a named rejection. + // The wire schema enforces the same shape later; this is the earlier gate. + for (const field of DEADLINE_FIELDS) { + if (!isPosixMsString(input[field])) { + throw new Error(`Masumi ${field} must be a positive POSIX-ms integer string`); + } + } + + // Ordering and minimum gaps: the same rule the client and facilitator apply. + if ( + !masumiDeadlineIntervalsHold( + BigInt(input.payByTime), + BigInt(input.submitResultTime), + BigInt(input.unlockTime), + BigInt(input.externalDisputeUnlockTime), + ) + ) { + throw new Error("Masumi deadline intervals are below the minimum"); + } + + assertMasumiIssueWindow( + { + payByTime: input.payByTime, + submitResultTime: input.submitResultTime, + externalDisputeUnlockTime: input.externalDisputeUnlockTime, + }, + input.maxTimeoutSeconds, + nowMs, + input.maxDeadlineHorizonMs ?? MASUMI_MAX_DEADLINE_HORIZON_MS, + ); +} + +/** + * The clock-relative half of the issue policy, split out because it has to run + * twice. + * + * `signTerms` is asynchronous and may sit behind a hardware wallet, a remote + * signer or a human approval, so an unbounded amount of time can pass between + * the first check and the moment the requirements are actually served. Without a + * second pass the issuer can emit a 402 whose `payByTime` has already expired, + * and the buyer would be the first to notice. + * + * Only the floors can newly fail on that second pass. The `maxTimeoutSeconds` + * ceiling is `now + maxTimeoutSeconds`, which moves forward with the clock, so + * a `payByTime` once inside it stays inside it; it is re-checked here only + * because this function is the whole window, not because it can trip late. + * + * @param deadlines - Deadlines already validated for shape and ordering. + * @param deadlines.payByTime - Datum `pay_by_time`. + * @param deadlines.submitResultTime - Datum `submit_result_time`. + * @param deadlines.externalDisputeUnlockTime - Datum `external_dispute_unlock_time`. + * @param maxTimeoutSeconds - The x402 validity window. + * @param nowMs - Current POSIX time in milliseconds. + * @param maxDeadlineHorizonMs - How far past now the last deadline may sit. + * @throws When the window no longer admits a payable 402. + */ +function assertMasumiIssueWindow( + deadlines: { payByTime: string; submitResultTime: string; externalDisputeUnlockTime: string }, + maxTimeoutSeconds: number, + nowMs: bigint, + maxDeadlineHorizonMs: bigint, +): void { + const payByTime = BigInt(deadlines.payByTime); + + // Absolute floors. Only the issuer holds a clock the buyer has not moved past. + if (payByTime <= nowMs) { + throw new Error("Masumi payByTime must be in the future"); + } + if (BigInt(deadlines.submitResultTime) < nowMs + MASUMI_MIN_SUBMIT_RESULT_LEAD_MS) { + throw new Error("Masumi submitResultTime must be at least 15 minutes away"); + } + // A buyer refuses a window it cannot escape: until `submit_result_time` the + // contract lets it recover neither the payment nor its collateral. + if (BigInt(deadlines.externalDisputeUnlockTime) > nowMs + maxDeadlineHorizonMs) { + throw new Error("Masumi deadlines extend beyond the accepted horizon"); + } + + // The buyer must be able to build, sign and land the lock inside the x402 + // validity window, and its transaction TTL is bounded by `payByTime`. + if (payByTime > nowMs + BigInt(maxTimeoutSeconds) * 1000n) { + throw new Error("Masumi payByTime exceeds maxTimeoutSeconds"); + } +} + +/** + * Generates a lowercase hex string of cryptographically random bytes. + * + * @param bytes - How many bytes to generate. + * @returns The lowercase hex encoding. + */ +function randomHex(bytes: number): string { + const buffer = new Uint8Array(bytes); + globalThis.crypto.getRandomValues(buffer); + return Array.from(buffer, b => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Issues a Masumi `PaymentRequirements`. + * + * `payTo` is derived from the deployment parameters, never supplied: the + * verifier re-derives the same address and rejects a mismatch. + * + * @param input - What to issue. + * @returns The complete payment requirements. + * @throws When the resulting `extra` would not satisfy the wire schema. + */ +export async function issueMasumiRequirements( + input: IssueMasumiRequirementsInput, +): Promise { + if (!POSITIVE_CANONICAL_AMOUNT_REGEX.test(input.amount)) { + throw new Error(`Masumi amount must be a positive canonical integer: ${input.amount}`); + } + if (!CANONICAL_CARDANO_ASSET_REGEX.test(input.asset)) { + throw new Error(`Masumi asset must use canonical lowercase form: ${input.asset}`); + } + // Input validity, not policy: `maxTimeoutSeconds` goes on the wire and into + // `termsDigest`, so it is never skippable. + if (!Number.isSafeInteger(input.maxTimeoutSeconds) || input.maxTimeoutSeconds <= 0) { + throw new Error("Masumi maxTimeoutSeconds must be a positive safe integer"); + } + // Strictly `true`: a truthy non-boolean reaching a security escape hatch from + // untyped configuration must not silently disable it. + const skipPolicyChecks = input.unsafeSkipPolicyChecks === true; + if (!skipPolicyChecks) { + assertMasumiIssuePolicy(input, BigInt(Date.now())); + } + const deployment = resolveMasumiDeployment(input.network, input.deployment); + if (!deployment) { + throw new Error( + `Network ${input.network} has no canonical Masumi deployment; supply extra.deployment`, + ); + } + const payTo = masumiEscrowAddress(input.network, deployment); + + const parts: MasumiCommitmentPart[] = input.commitment.map(part => ({ + name: part.name, + canonicalization: part.canonicalization, + ...(part.mediaType !== undefined ? { mediaType: part.mediaType } : {}), + ...(part.echoContent === false ? {} : { content: part.content }), + digest: commitmentPartDigest(part), + })); + const inputCommitment: MasumiInputCommitment = { + version: "1", + algorithm: "sha256", + parts, + digest: "", + }; + inputCommitment.digest = computeInputHash(inputCommitment); + + const terms: MasumiTerms = { + version: "1", + paymentType: MASUMI_PAYMENT_SOURCE_TYPE, + sellerAddress: input.sellerAddress, + ...(input.sellerReturnAddress !== undefined + ? { sellerReturnAddress: input.sellerReturnAddress } + : {}), + sellerNonce: input.sellerNonce ?? randomHex(32), + buyerNonce: input.buyerNonce ?? "", + ...(input.agentIdentifier !== undefined ? { agentIdentifier: input.agentIdentifier } : {}), + inputHash: inputCommitment.digest, + payByTime: input.payByTime, + submitResultTime: input.submitResultTime, + unlockTime: input.unlockTime, + externalDisputeUnlockTime: input.externalDisputeUnlockTime, + // This reference implementation currently has no Hydra client, so its + // issuer deliberately selects L1 instead of advertising `auto` and later + // being unable to honor a Hydra-capable buyer's choice. + settlementPolicy: input.settlementPolicy ?? "l1", + }; + + const requirements: PaymentRequirements = { + scheme: "exact", + network: input.network as PaymentRequirements["network"], + asset: input.asset, + amount: input.amount, + payTo, + maxTimeoutSeconds: input.maxTimeoutSeconds, + extra: {}, + }; + + // The digest covers `terms` plus the seven projected top-level fields, so it + // can only be computed once the requirements above are fixed. + const termsDigest = computeTermsDigest( + buildSignedTerms( + { + assetTransferMethod: ASSET_TRANSFER_METHOD_MASUMI, + inputCommitment, + terms, + referenceKey: "", + referenceSignature: "", + blockchainIdentifier: "", + }, + requirements, + ), + ); + const authorization = await input.signTerms(input.sellerAddress, termsDigest); + const referenceKey = authorization.key.toLowerCase(); + const referenceSignature = authorization.signature.toLowerCase(); + + // Signing is asynchronous and unbounded — a hardware wallet, a remote signer + // or a human approval can take minutes. Re-check the clock-relative rules + // against `terms` and `requirements`, which are this function's own copies, so + // a caller mutating `input` mid-flight cannot steer the second pass. + if (!skipPolicyChecks) { + assertMasumiIssueWindow( + terms, + requirements.maxTimeoutSeconds, + BigInt(Date.now()), + input.maxDeadlineHorizonMs ?? MASUMI_MAX_DEADLINE_HORIZON_MS, + ); + } + + const extra: CardanoExtraMasumi = { + assetTransferMethod: ASSET_TRANSFER_METHOD_MASUMI, + ...(input.submissionPolicy !== undefined ? { submissionPolicy: input.submissionPolicy } : {}), + ...(input.confirmationPolicy !== undefined + ? { confirmationPolicy: input.confirmationPolicy } + : {}), + inputCommitment, + terms, + referenceKey, + referenceSignature, + blockchainIdentifier: encodeBlockchainIdentifier({ + sellerNonce: terms.sellerNonce, + agentIdentifier: typeof terms.agentIdentifier === "string" ? terms.agentIdentifier : "", + buyerNonce: terms.buyerNonce, + referenceSignature, + referenceKey, + contractAddress: payTo, + }), + ...(input.deployment ? { deployment: input.deployment } : {}), + }; + + // Fail here rather than serving a 402 no client or facilitator will accept. + const schema = validateMasumiExtra(extra, input.network); + if (!schema.ok) { + throw new Error(`Issued Masumi requirements are invalid: ${schema.detail}`); + } + + requirements.extra = extra as unknown as Record; + return requirements; +} + +/** + * Builds a mnemonic-backed seller: its key-credential address and a + * {@link MasumiTermsSigner} that produces the CIP-8 authorization with the same + * key. Convenience for a resource server that holds the selling wallet directly; + * a CIP-30 wallet integration supplies its own signer instead. + * + * @param config - The seller wallet configuration. + * @param config.mnemonic - BIP-39 mnemonic of the selling wallet. + * @param config.network - The x402 Cardano network identifier. + * @param config.accountIndex - Optional derivation account index. + * @returns The seller address and its terms signer. + */ +export function toMasumiSellerSigner(config: { + mnemonic: string; + network: string; + accountIndex?: number; +}): { sellerAddress: string; signTerms: MasumiTermsSigner } { + const mnemonic = config.mnemonic.trim().replace(/\s+/g, " ").toLowerCase(); + const derivation = { + accountIndex: config.accountIndex, + networkId: getCardanoNetworkId(config.network), + }; + const privateKey = PrivateKey.fromBech32(keysFromSeed(mnemonic, derivation).paymentKey); + const sellerAddress = Address.toBech32(addressFromSeed(mnemonic, derivation).address); + + return { + sellerAddress, + signTerms: (address, termsDigestHex) => { + const signed = COSE.SignData.signData( + Address.toHex(Address.fromBech32(address)), + Uint8Array.from(Buffer.from(termsDigestHex, "hex")), + privateKey, + ); + return { + key: Buffer.from(signed.key).toString("hex").toLowerCase(), + signature: Buffer.from(signed.signature).toString("hex").toLowerCase(), + }; + }, + }; +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/jcs.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/jcs.ts new file mode 100644 index 0000000000..1308d31ad0 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/jcs.ts @@ -0,0 +1,92 @@ +/** + * RFC 8785 JSON Canonicalization Scheme. + * + * Both Masumi digests (`inputHash` and `termsDigest`) are taken over + * `JCS(value)`, so client, resource server and facilitator only agree when they + * serialize identically. The rules that matter here: + * + * - object members are sorted by the UTF-16 code units of their names, which is + * exactly JavaScript's default string ordering; + * - numbers use the ECMAScript `Number::toString` form, which is what + * `JSON.stringify` emits; + * - strings use JSON escaping with the short forms for `\b \t \n \f \r`. + * + * A member whose value is `undefined` is omitted (it is absent from the JSON + * document), matching `JSON.stringify`. Values JSON cannot represent — `NaN`, + * `Infinity`, functions, symbols, `bigint` — are rejected rather than silently + * coerced, because a silent coercion would produce a digest the counterparty + * cannot reproduce. + */ + +/** + * Serializes a string with JSON escaping, refusing invalid Unicode. + * + * RFC 8785 requires canonicalization to fail on data that is not valid Unicode. + * `JSON.stringify` instead escapes an unpaired UTF-16 surrogate into `\udXXX`, + * so a JavaScript peer would happily produce a digest that a conforming + * implementation in another language refuses to compute — a silent + * disagreement on `inputHash` and `termsDigest`. + * + * @param value - The string to serialize. + * @returns The JSON-escaped string. + * @throws When the string contains an unpaired surrogate. + */ +function serializeString(value: string): string { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0xd800 || code > 0xdfff) continue; + const isHighSurrogate = code <= 0xdbff; + const next = isHighSurrogate ? value.charCodeAt(i + 1) : Number.NaN; + if (!isHighSurrogate || !(next >= 0xdc00 && next <= 0xdfff)) { + throw new Error("JCS cannot serialize a string containing an unpaired surrogate"); + } + i++; + } + return JSON.stringify(value); +} + +/** + * Canonicalizes a JSON value per RFC 8785. + * + * @param value - An RFC 8785-compatible JSON value. + * @returns The canonical JSON text. + * @throws When the value contains something JSON cannot represent. + */ +export function jcs(value: unknown): string { + if (value === null) return "null"; + switch (typeof value) { + case "boolean": + return value ? "true" : "false"; + case "number": + if (!Number.isFinite(value)) { + throw new Error(`JCS cannot serialize the non-finite number ${value}`); + } + return JSON.stringify(value); + case "string": + return serializeString(value); + case "object": + break; + default: + throw new Error(`JCS cannot serialize a value of type ${typeof value}`); + } + + if (Array.isArray(value)) { + return `[${value.map(item => jcs(item === undefined ? null : item)).join(",")}]`; + } + const record = value as Record; + const members = Object.keys(record) + .filter(key => record[key] !== undefined) + .sort() + .map(key => `${serializeString(key)}:${jcs(record[key])}`); + return `{${members.join(",")}}`; +} + +/** + * UTF-8 bytes of the canonical form of a JSON value. + * + * @param value - An RFC 8785-compatible JSON value. + * @returns The canonical UTF-8 bytes. + */ +export function jcsBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(jcs(value)); +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/lock.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/lock.ts index 8a6d34f573..af4c346a45 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/masumi/lock.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/lock.ts @@ -1,58 +1,126 @@ -import { InlineDatum } from "@evolution-sdk/evolution"; +import { Data, InlineDatum } from "@evolution-sdk/evolution"; +import { LOVELACE_ASSET } from "../../constants"; import type { CardanoExtraMasumi } from "../../types"; -import { MASUMI_DEFAULT_COLLATERAL_LOVELACE } from "./constants"; +import { masumiCollateralLovelace } from "./constants"; import { buildMasumiLockDatum, inlineDatum } from "./datum"; /** - * Reads a required masumi `extra` field, throwing when it is missing or empty. - * These values are purchase-bound (supplied from the Masumi purchase); the - * client must not invent them, so an absent value is a caller error. - * - * @param extra - The masumi `extra` block. - * @param key - The required field name. - * @returns The field value. + * Buyer-side inputs to the lock. Everything else in the datum comes from the + * seller-signed `terms` — including `buyer_nonce` and `input_hash`, which the + * seller signs, so the client must not invent them. */ -function requireMasumiField(extra: CardanoExtraMasumi, key: keyof CardanoExtraMasumi): string { - const value = extra[key]; - if (typeof value !== "string" || value.length === 0) { - throw new Error(`Masumi payment requires "${String(key)}" in requirements.extra`); - } - return value; +export interface MasumiBuyerInput { + /** + * datum `buyer_return_address`. Buyer-chosen and never declared by the + * server, so the facilitator does not match it against `extra`. Defaults to + * absent (`None`). It MUST differ from the effective seller payout target. + */ + buyerReturnAddress?: string; +} + +/** + * A built Masumi escrow lock: the inline datum plus the value the escrow output + * must carry. + */ +export interface MasumiLock { + /** The inline datum to attach to the `payTo` output. */ + datum: InlineDatum.InlineDatum; + /** datum `collateral_return_lovelace`. */ + collateralLovelace: bigint; + /** Lovelace on the escrow output: `requestedLovelace + collateralLovelace`. */ + lockedLovelace: bigint; } /** - * Builds the Masumi `vested_pay` lock inline datum from the payment `extra`. - * Only `input_hash` (empty) and `collateral_return_lovelace` (0) fall back to - * the contract defaults; all other fields are required. + * The collateral is a datum field, so growing it grows the datum, which raises + * the post-`SubmitResult` min-UTXO it has to clear. Re-deriving it a few times + * reaches the fixed point; four rounds is far more than the one or two byte-length + * changes a realistic integer encoding produces. + */ +const COLLATERAL_FIXED_POINT_ROUNDS = 4; + +/** + * Builds the Masumi `vested_pay` lock: the 19-field inline datum and the + * lovelace the escrow output must carry. + * + * The seller never supplies or signs `collateral_return_lovelace` — the client + * computes it from the requested asset and live protocol parameters so that + * `lockedLovelace = requestedLovelace + collateral` still clears the min-UTXO + * of the datum **after** `SubmitResult`. Otherwise the seller could never spend + * the escrow. * * @param extra - The masumi `extra` block from the payment requirements. * @param buyerAddress - The payer wallet bech32 address (datum `buyer`). - * @returns The inline datum for the escrow output. + * @param asset - The requested asset unit. + * @param amount - The requested amount in the asset's smallest unit. + * @param coinsPerUtxoByte - Live `coinsPerUtxoByte` protocol parameter. + * @param buyerInput - Buyer-side datum inputs. + * @returns The inline datum and the escrow output's value. */ -export function buildMasumiLockInline( +export function buildMasumiLock( extra: CardanoExtraMasumi, buyerAddress: string, -): InlineDatum.InlineDatum { - const datum = buildMasumiLockDatum({ - buyerAddress, - sellerAddress: requireMasumiField(extra, "sellerAddress"), - buyerReturnAddress: extra.buyerReturnAddress, - sellerReturnAddress: extra.sellerReturnAddress, - referenceKey: requireMasumiField(extra, "referenceKey"), - referenceSignature: requireMasumiField(extra, "referenceSignature"), - sellerNonce: requireMasumiField(extra, "sellerNonce"), - buyerNonce: requireMasumiField(extra, "identifierFromPurchaser"), - agentIdentifier: requireMasumiField(extra, "agentIdentifier"), - collateralReturnLovelace: - extra.collateralReturnLovelace !== undefined - ? BigInt(extra.collateralReturnLovelace) - : MASUMI_DEFAULT_COLLATERAL_LOVELACE, - inputHash: extra.inputHash ?? "", - payByTime: BigInt(requireMasumiField(extra, "payByTime")), - submitResultTime: BigInt(requireMasumiField(extra, "submitResultTime")), - unlockTime: BigInt(requireMasumiField(extra, "unlockTime")), - externalDisputeUnlockTime: BigInt(requireMasumiField(extra, "externalDisputeUnlockTime")), - }); - return inlineDatum(datum); + asset: string, + amount: bigint, + coinsPerUtxoByte: bigint, + buyerInput: MasumiBuyerInput = {}, +): MasumiLock { + const { terms } = extra; + const isLovelace = asset.toLowerCase() === LOVELACE_ASSET; + const requestedLovelace = isLovelace ? amount : 0n; + const nativeTokenCount = isLovelace ? 0 : 1; + + /** + * Builds the datum for a candidate collateral. + * + * @param collateral - The candidate `collateral_return_lovelace`. + * @returns The Plutus datum. + */ + const build = (collateral: bigint): Data.Data => + buildMasumiLockDatum({ + buyerAddress, + sellerAddress: terms.sellerAddress, + buyerReturnAddress: buyerInput.buyerReturnAddress, + sellerReturnAddress: terms.sellerReturnAddress, + referenceKey: extra.referenceKey, + referenceSignature: extra.referenceSignature, + sellerNonce: terms.sellerNonce, + buyerNonce: terms.buyerNonce, + agentIdentifier: typeof terms.agentIdentifier === "string" ? terms.agentIdentifier : "", + collateralReturnLovelace: collateral, + inputHash: terms.inputHash, + payByTime: BigInt(terms.payByTime), + submitResultTime: BigInt(terms.submitResultTime), + unlockTime: BigInt(terms.unlockTime), + externalDisputeUnlockTime: BigInt(terms.externalDisputeUnlockTime), + }); + + let collateral = 0n; + let datum = build(collateral); + let converged = false; + for (let round = 0; round < COLLATERAL_FIXED_POINT_ROUNDS; round++) { + const needed = masumiCollateralLovelace( + requestedLovelace, + Data.toCBORHex(datum).length / 2, + nativeTokenCount, + coinsPerUtxoByte, + ); + // The current datum already clears its own min-UTXO at this collateral. + if (needed <= collateral) { + converged = true; + break; + } + collateral = needed; + datum = build(collateral); + } + if (!converged) { + throw new Error("Masumi collateral did not converge; refusing to build an unspendable lock"); + } + + return { + datum: inlineDatum(datum), + collateralLovelace: collateral, + lockedLovelace: requestedLovelace + collateral, + }; } diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/schema.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/schema.ts new file mode 100644 index 0000000000..7424067e6e --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/schema.ts @@ -0,0 +1,543 @@ +import { AddressEras } from "@evolution-sdk/evolution"; + +import { getCardanoNetworkId } from "../../constants"; +import { + MAX_MASUMI_ADMIN_KEYS, + MAX_MASUMI_COMMITMENT_CONTENT_BYTES, + MAX_MASUMI_COMMITMENT_PARTS, + MAX_MASUMI_COSE_BYTES, + MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES, +} from "../../limits"; +import { normalizeConfirmationPolicy, normalizeSubmissionPolicy } from "../../policy"; +import type { + CardanoExtraMasumi, + MasumiCommitmentPart, + MasumiDeployment, + MasumiInputCommitment, + MasumiTerms, +} from "../../types"; +import { MASUMI_PAYMENT_SOURCE_TYPE } from "./constants"; + +/** + * Wire-schema validation for the Masumi `extra` block. + * + * `extra`, `inputCommitment`, every commitment part, `terms`, + * `confirmationPolicy` and `deployment` are **closed objects**: an unknown + * field is invalid. Because the allowed key sets below exclude every field that + * is projected into `signedTerms` from the top level, the closed-object check + * also enforces that `terms` never repeats one of them. + */ + +/** Result of a schema validation: the typed block, or why it was rejected. */ +export type MasumiSchemaResult = + | { ok: true; extra: CardanoExtraMasumi } + | { ok: false; detail: string }; + +const EXTRA_KEYS = new Set([ + "assetTransferMethod", + "submissionPolicy", + "confirmationPolicy", + "inputCommitment", + "terms", + "referenceKey", + "referenceSignature", + "blockchainIdentifier", + "deployment", +]); + +const COMMITMENT_KEYS = new Set(["version", "algorithm", "parts", "digest"]); + +const PART_KEYS = new Set(["name", "canonicalization", "mediaType", "content", "digest"]); + +const TERMS_KEYS = new Set([ + "version", + "paymentType", + "sellerAddress", + "sellerReturnAddress", + "sellerNonce", + "buyerNonce", + "agentIdentifier", + "inputHash", + "payByTime", + "submitResultTime", + "unlockTime", + "externalDisputeUnlockTime", + "settlementPolicy", +]); + +const DEPLOYMENT_KEYS = new Set(["requiredAdmins", "adminVkeys", "cooldownPeriod"]); + +/** Lowercase even-length hex, possibly empty. */ +const HEX = /^([0-9a-f]{2})*$/; +/** Exactly 32 bytes of lowercase hex. */ +const HEX_32_BYTES = /^[0-9a-f]{64}$/; +/** Exactly 28 bytes of lowercase hex. */ +const HEX_28_BYTES = /^[0-9a-f]{56}$/; +/** Positive canonical base-10 integer with no leading zero. */ +const POSITIVE_INT = /^[1-9][0-9]*$/; +/** Non-negative canonical base-10 integer with no leading zero. */ +const NON_NEGATIVE_INT = /^(0|[1-9][0-9]*)$/; +const BASE64URL = /^[A-Za-z0-9_-]*$/; +const MAX_JSON_DEPTH = 64; +const MAX_JSON_VALUES = 100_000; +const MAX_PART_NAME_CHARS = 128; +const MAX_MEDIA_TYPE_CHARS = 256; +const MAX_POSIX_DIGITS = 20; +const MAX_AGENT_IDENTIFIER_HEX_CHARS = 120; + +/** + * Whether a value is a plain (non-array, non-null) object. + * + * @param value - The value to test. + * @returns True for a plain object. + */ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Names the first key of `value` that is not in `allowed`. + * + * @param value - The object to check. + * @param allowed - The closed key set. + * @returns The offending key, or `undefined` when every key is allowed. + */ +function unknownKey(value: Record, allowed: Set): string | undefined { + return Object.keys(value).find(key => !allowed.has(key)); +} + +/** + * Checks one commitment payload before digest code recursively canonicalizes it. + * + * @param value - Declared part content. + * @param canonicalization - Declared byte encoding. + * @returns Rejection detail, or undefined when within budget. + */ +function commitmentContentError( + value: unknown, + canonicalization: "jcs" | "raw", +): string | undefined { + if (canonicalization === "raw") { + if (typeof value !== "string" || !BASE64URL.test(value)) { + return "raw content must be canonical unpadded base64url"; + } + const decoded = Buffer.from(value, "base64url"); + if ( + decoded.length > MAX_MASUMI_COMMITMENT_CONTENT_BYTES || + decoded.toString("base64url") !== value + ) { + return "raw content exceeds the byte limit or is not canonical base64url"; + } + return undefined; + } + + const pending: Array< + { value: unknown; depth: number; leave?: false } | { value: object; leave: true } + > = [{ value, depth: 0 }]; + const seen = new WeakSet(); + let values = 0; + let bytes = 0; + while (pending.length > 0) { + const current = pending.pop()!; + if (current.leave) { + seen.delete(current.value); + continue; + } + values++; + if (values > MAX_JSON_VALUES) return "JCS content exceeds the value limit"; + if (current.depth > MAX_JSON_DEPTH) return "JCS content exceeds the nesting limit"; + if (current.value === null || typeof current.value === "boolean") continue; + if (typeof current.value === "number") { + if (!Number.isFinite(current.value)) return "JCS content contains a non-finite number"; + continue; + } + if (typeof current.value === "string") { + bytes += Buffer.byteLength(current.value, "utf8"); + if (bytes > MAX_MASUMI_COMMITMENT_CONTENT_BYTES) { + return "JCS content exceeds the byte limit"; + } + continue; + } + if (typeof current.value !== "object") return "JCS content is not valid JSON"; + if (seen.has(current.value)) return "JCS content contains a cycle"; + seen.add(current.value); + pending.push({ value: current.value, leave: true }); + if (Array.isArray(current.value)) { + for (const item of current.value) { + pending.push({ value: item, depth: current.depth + 1 }); + } + continue; + } + const prototype = Object.getPrototypeOf(current.value); + if (prototype !== Object.prototype && prototype !== null) { + return "JCS content must contain only plain JSON objects"; + } + for (const [key, item] of Object.entries(current.value as Record)) { + bytes += Buffer.byteLength(key, "utf8"); + if (bytes > MAX_MASUMI_COMMITMENT_CONTENT_BYTES) { + return "JCS content exceeds the byte limit"; + } + pending.push({ value: item, depth: current.depth + 1 }); + } + } + return undefined; +} + +/** + * Whether a value is a deadline the scheme can safely convert with `BigInt`: a + * bounded, positive, canonical decimal string of POSIX milliseconds. + * + * Callers rely on this as a guard, not just as a schema rule — `BigInt("")` and + * `BigInt("12x")` throw a raw `SyntaxError`, which would escape a validator as + * an unhelpful crash instead of a precise rejection. + * + * @param value - The candidate deadline. + * @returns True when the value converts cleanly. + */ +export function isPosixMsString(value: unknown): value is string { + return typeof value === "string" && value.length <= MAX_POSIX_DIGITS && POSITIVE_INT.test(value); +} + +/** + * Whether a bech32 address parses, belongs to the selected network, and uses an + * address form the Masumi escrow lifecycle can carry end to end: an enterprise + * address with a key payment credential, or a base address whose payment **and** + * stake credentials are both key hashes. + * + * A script payment credential is refused because `vested_pay` does + * `expect Some(vk) = address_to_verification_key(...)` on every spend path — the + * contract could never release the funds. A script stake credential and a + * pointer stake reference are refused for a narrower but equally terminal + * reason: Masumi's own `getPubKeyAddressDatum` accepts neither, and every later + * transition rebuilds the continuation datum through it while the validator + * demands `new_datum.buyer == buyer` exactly. Locking such an address strands + * the escrow for Masumi tooling with no recovery path. + * + * @param value - The candidate address. + * @param network - The x402 Cardano network identifier. + * @returns True when the address is a key-credential address on that network. + */ +export function isKeyCredentialAddressOn(value: unknown, network: string): boolean { + if (typeof value !== "string" || value.length === 0) return false; + try { + const address = AddressEras.fromBech32(value); + if (address._tag !== "BaseAddress" && address._tag !== "EnterpriseAddress") return false; + if (address.paymentCredential._tag !== "KeyHash") return false; + if (address._tag === "BaseAddress" && address.stakeCredential._tag !== "KeyHash") return false; + return address.networkId === getCardanoNetworkId(network); + } catch { + return false; + } +} + +/** + * Validates the closed `deployment` block. + * + * @param value - The raw `extra.deployment` value. + * @returns The typed deployment, or why it was rejected. + */ +function validateDeployment( + value: unknown, +): { ok: true; deployment: MasumiDeployment } | { ok: false; detail: string } { + if (!isRecord(value)) return { ok: false, detail: "deployment must be an object" }; + const extraneous = unknownKey(value, DEPLOYMENT_KEYS); + if (extraneous) return { ok: false, detail: `deployment has unknown field ${extraneous}` }; + + const { requiredAdmins, adminVkeys, cooldownPeriod } = value; + if ( + !Array.isArray(adminVkeys) || + adminVkeys.length === 0 || + adminVkeys.length > MAX_MASUMI_ADMIN_KEYS + ) { + return { ok: false, detail: "deployment.adminVkeys must be a non-empty array" }; + } + if (!adminVkeys.every(vkey => typeof vkey === "string" && HEX_28_BYTES.test(vkey))) { + return { ok: false, detail: "deployment.adminVkeys must be 28-byte lowercase hex" }; + } + if ( + typeof requiredAdmins !== "string" || + requiredAdmins.length > 3 || + !POSITIVE_INT.test(requiredAdmins) + ) { + return { ok: false, detail: "deployment.requiredAdmins must be a positive integer string" }; + } + if (BigInt(requiredAdmins) > BigInt(adminVkeys.length)) { + return { ok: false, detail: "deployment.requiredAdmins exceeds adminVkeys length" }; + } + if ( + typeof cooldownPeriod !== "string" || + cooldownPeriod.length > MAX_POSIX_DIGITS || + !NON_NEGATIVE_INT.test(cooldownPeriod) + ) { + return { + ok: false, + detail: "deployment.cooldownPeriod must be a non-negative integer string", + }; + } + return { + ok: true, + deployment: { requiredAdmins, adminVkeys: adminVkeys as string[], cooldownPeriod }, + }; +} + +/** + * Validates one closed commitment part. + * + * @param value - The raw part. + * @param index - Its position, used in the rejection detail. + * @returns The typed part, or why it was rejected. + */ +function validatePart( + value: unknown, + index: number, +): { ok: true; part: MasumiCommitmentPart } | { ok: false; detail: string } { + if (!isRecord(value)) return { ok: false, detail: `parts[${index}] must be an object` }; + const extraneous = unknownKey(value, PART_KEYS); + if (extraneous) return { ok: false, detail: `parts[${index}] has unknown field ${extraneous}` }; + + const { name, canonicalization, mediaType, digest } = value; + if (typeof name !== "string" || name.length === 0 || name.length > MAX_PART_NAME_CHARS) { + return { ok: false, detail: `parts[${index}].name must be a non-empty string` }; + } + if (canonicalization !== "jcs" && canonicalization !== "raw") { + return { ok: false, detail: `parts[${index}].canonicalization must be jcs or raw` }; + } + if ( + mediaType !== undefined && + (typeof mediaType !== "string" || mediaType.length > MAX_MEDIA_TYPE_CHARS) + ) { + return { ok: false, detail: `parts[${index}].mediaType must be a string` }; + } + if (typeof digest !== "string" || !HEX_32_BYTES.test(digest)) { + return { ok: false, detail: `parts[${index}].digest must be 32-byte lowercase hex` }; + } + if ("content" in value) { + const contentError = commitmentContentError(value.content, canonicalization); + if (contentError) return { ok: false, detail: `parts[${index}].content ${contentError}` }; + } + return { + ok: true, + part: { + name, + canonicalization, + ...(mediaType !== undefined ? { mediaType } : {}), + ...("content" in value ? { content: value.content } : {}), + digest, + }, + }; +} + +/** + * Validates the closed `inputCommitment` block. + * + * @param value - The raw commitment. + * @returns The typed commitment, or why it was rejected. + */ +function validateCommitment( + value: unknown, +): { ok: true; commitment: MasumiInputCommitment } | { ok: false; detail: string } { + if (!isRecord(value)) return { ok: false, detail: "inputCommitment must be an object" }; + const extraneous = unknownKey(value, COMMITMENT_KEYS); + if (extraneous) return { ok: false, detail: `inputCommitment has unknown field ${extraneous}` }; + + if (value.version !== "1") return { ok: false, detail: "inputCommitment.version must be '1'" }; + if (value.algorithm !== "sha256") { + return { ok: false, detail: "inputCommitment.algorithm must be 'sha256'" }; + } + if (typeof value.digest !== "string" || !HEX_32_BYTES.test(value.digest)) { + return { ok: false, detail: "inputCommitment.digest must be 32-byte lowercase hex" }; + } + if ( + !Array.isArray(value.parts) || + value.parts.length === 0 || + value.parts.length > MAX_MASUMI_COMMITMENT_PARTS + ) { + return { ok: false, detail: "inputCommitment.parts must be a non-empty array" }; + } + + const parts: MasumiCommitmentPart[] = []; + const names = new Set(); + for (const [index, raw] of value.parts.entries()) { + const result = validatePart(raw, index); + if (!result.ok) return result; + if (names.has(result.part.name)) { + return { ok: false, detail: `inputCommitment has duplicate part name ${result.part.name}` }; + } + names.add(result.part.name); + parts.push(result.part); + } + return { + ok: true, + commitment: { version: "1", algorithm: "sha256", parts, digest: value.digest }, + }; +} + +/** + * Validates the closed `terms` block against the selected network. + * + * @param value - The raw terms. + * @param network - The x402 Cardano network identifier. + * @param commitmentDigest - The validated `inputCommitment.digest`. + * @returns The typed terms, or why they were rejected. + */ +function validateTerms( + value: unknown, + network: string, + commitmentDigest: string, +): { ok: true; terms: MasumiTerms } | { ok: false; detail: string } { + if (!isRecord(value)) return { ok: false, detail: "terms must be an object" }; + const extraneous = unknownKey(value, TERMS_KEYS); + if (extraneous) return { ok: false, detail: `terms has unknown field ${extraneous}` }; + + if (value.version !== "1") return { ok: false, detail: "terms.version must be '1'" }; + if (value.paymentType !== MASUMI_PAYMENT_SOURCE_TYPE) { + return { ok: false, detail: `terms.paymentType must be ${MASUMI_PAYMENT_SOURCE_TYPE}` }; + } + if (!isKeyCredentialAddressOn(value.sellerAddress, network)) { + return { ok: false, detail: "terms.sellerAddress must be a key-credential address on network" }; + } + // An absent return address is OMITTED; JSON null is explicitly invalid. + if ( + "sellerReturnAddress" in value && + !isKeyCredentialAddressOn(value.sellerReturnAddress, network) + ) { + return { + ok: false, + detail: "terms.sellerReturnAddress must be a key-credential address on network", + }; + } + if (typeof value.sellerNonce !== "string" || !HEX_32_BYTES.test(value.sellerNonce)) { + return { ok: false, detail: "terms.sellerNonce must be 32-byte lowercase hex" }; + } + if ( + typeof value.buyerNonce !== "string" || + !HEX.test(value.buyerNonce) || + (value.buyerNonce.length !== 0 && + (value.buyerNonce.length < 14 || value.buyerNonce.length > 26)) + ) { + return { ok: false, detail: "terms.buyerNonce must be empty or 14-26 lowercase hex chars" }; + } + if ( + "agentIdentifier" in value && + value.agentIdentifier !== null && + (typeof value.agentIdentifier !== "string" || + value.agentIdentifier.length > MAX_AGENT_IDENTIFIER_HEX_CHARS || + !HEX.test(value.agentIdentifier)) + ) { + return { ok: false, detail: "terms.agentIdentifier must be null or lowercase hex" }; + } + if (value.inputHash !== commitmentDigest) { + return { ok: false, detail: "terms.inputHash must equal inputCommitment.digest" }; + } + const times = ["payByTime", "submitResultTime", "unlockTime", "externalDisputeUnlockTime"]; + for (const field of times) { + if (!isPosixMsString(value[field])) { + return { ok: false, detail: `terms.${field} must be a positive POSIX-ms integer string` }; + } + } + if ( + value.settlementPolicy !== "auto" && + value.settlementPolicy !== "l1" && + value.settlementPolicy !== "hydra" + ) { + return { ok: false, detail: "terms.settlementPolicy must be auto, l1 or hydra" }; + } + + return { + ok: true, + terms: { + version: "1", + paymentType: MASUMI_PAYMENT_SOURCE_TYPE, + sellerAddress: value.sellerAddress as string, + ...("sellerReturnAddress" in value + ? { sellerReturnAddress: value.sellerReturnAddress as string } + : {}), + sellerNonce: value.sellerNonce, + buyerNonce: value.buyerNonce, + ...("agentIdentifier" in value + ? { agentIdentifier: value.agentIdentifier as string | null } + : {}), + inputHash: value.inputHash as string, + payByTime: value.payByTime as string, + submitResultTime: value.submitResultTime as string, + unlockTime: value.unlockTime as string, + externalDisputeUnlockTime: value.externalDisputeUnlockTime as string, + settlementPolicy: value.settlementPolicy, + }, + }; +} + +/** + * Validates a Masumi `extra` block against the wire schema. + * + * This checks structure only — digests, signatures, the derived escrow address + * and the datum are verified separately. + * + * @param value - The raw `requirements.extra`. + * @param network - The x402 Cardano network identifier. + * @returns The typed extra, or why it was rejected. + */ +export function validateMasumiExtra(value: unknown, network: string): MasumiSchemaResult { + if (!isRecord(value)) return { ok: false, detail: "extra must be an object" }; + const extraneous = unknownKey(value, EXTRA_KEYS); + if (extraneous) return { ok: false, detail: `extra has unknown field ${extraneous}` }; + + if (value.assetTransferMethod !== "masumi") { + return { ok: false, detail: "extra.assetTransferMethod must be masumi" }; + } + if (normalizeSubmissionPolicy(value.submissionPolicy) === null) { + return { ok: false, detail: "extra.submissionPolicy must be server, client or either" }; + } + if (normalizeConfirmationPolicy(value.confirmationPolicy) === null) { + return { ok: false, detail: "extra.confirmationPolicy must be { l1Confirmations: -1..20 }" }; + } + for (const field of ["referenceKey", "referenceSignature", "blockchainIdentifier"] as const) { + const hex = value[field]; + const maxBytes = + field === "blockchainIdentifier" + ? MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES + : MAX_MASUMI_COSE_BYTES; + if ( + typeof hex !== "string" || + hex.length === 0 || + hex.length / 2 > maxBytes || + !HEX.test(hex) + ) { + return { ok: false, detail: `extra.${field} must be non-empty lowercase even-length hex` }; + } + } + + const commitment = validateCommitment(value.inputCommitment); + if (!commitment.ok) return commitment; + const terms = validateTerms(value.terms, network, commitment.commitment.digest); + if (!terms.ok) return terms; + + let deployment: MasumiDeployment | undefined; + if (value.deployment !== undefined) { + const result = validateDeployment(value.deployment); + if (!result.ok) return result; + deployment = result.deployment; + } + + return { + ok: true, + extra: { + assetTransferMethod: "masumi", + ...(value.submissionPolicy !== undefined + ? { submissionPolicy: value.submissionPolicy as CardanoExtraMasumi["submissionPolicy"] } + : {}), + ...(value.confirmationPolicy !== undefined + ? { + confirmationPolicy: + value.confirmationPolicy as CardanoExtraMasumi["confirmationPolicy"], + } + : {}), + inputCommitment: commitment.commitment, + terms: terms.terms, + referenceKey: value.referenceKey as string, + referenceSignature: value.referenceSignature as string, + blockchainIdentifier: value.blockchainIdentifier as string, + ...(deployment ? { deployment } : {}), + }, + }; +} diff --git a/typescript/packages/mechanisms/cardano/src/exact/masumi/verify.ts b/typescript/packages/mechanisms/cardano/src/exact/masumi/verify.ts index 5b933e62bc..8d1f5a7118 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/masumi/verify.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/masumi/verify.ts @@ -1,36 +1,170 @@ -import type { PaymentRequirements } from "@x402/core/types"; +import type { PaymentRequirements, ResourceInfo } from "@x402/core/types"; import { + ERR_MASUMI_AGENT_IDENTIFIER, ERR_MASUMI_ASSET, ERR_MASUMI_COLLATERAL, - ERR_MASUMI_CONTRACT_MISMATCH, + ERR_MASUMI_COMMITMENT, ERR_MASUMI_DATUM_INVALID, ERR_MASUMI_DATUM_MISMATCH, ERR_MASUMI_DATUM_MISSING, ERR_MASUMI_DEADLINE, + ERR_MASUMI_DEPLOYMENT, + ERR_MASUMI_ESCROW_OUTPUT_COUNT, + ERR_MASUMI_IDENTIFIER, ERR_MASUMI_MIN_UTXO, ERR_MASUMI_REFERENCE_SCRIPT, + ERR_MASUMI_SCHEMA, + ERR_MASUMI_SELLER_SIGNATURE, + ERR_SETTLEMENT_LAYER_MISMATCH, + ERR_SETTLEMENT_LAYER_UNSUPPORTED, LOVELACE_ASSET, } from "../../constants"; -import type { CardanoExtraMasumi, DecodedCardanoTransaction } from "../../types"; +import type { + CardanoExtraMasumi, + DecodedCardanoTransaction, + ExactCardanoPayload, + MasumiDeployment, +} from "../../types"; import { slotToPosixMs } from "../../utils"; -import { MASUMI_MIN_COLLATERAL_LOVELACE, masumiMinUtxoLovelace } from "./constants"; +import { masumiEscrowAddress, resolveMasumiDeployment } from "./blueprint"; +import { + MASUMI_MIN_COLLATERAL_LOVELACE, + MASUMI_REGISTRY_POLICY_ID, + masumiDeadlineIntervalsHold, + masumiMinUtxoLovelace, +} from "./constants"; +import { verifySellerTermsSignature } from "./cose"; +import { + buildSignedTerms, + commitmentPartDigest, + computeInputHash, + computeTermsDigest, +} from "./digests"; +import { decodeBlockchainIdentifier } from "./identifier"; +import { validateMasumiExtra } from "./schema"; import { addressCredentials, MASUMI_STATE_FUNDS_LOCKED, parseMasumiLockDatum, type MasumiAddressCredentials, + type MasumiDatumView, } from "./datum"; -type Check = { ok: true } | { ok: false; reason: string }; +export type MasumiLockCheck = { ok: true } | { ok: false; reason: string; detail?: string }; /** * Builds a rejection result carrying the failure reason. * * @param reason - The failure reason code. + * @param detail - Optional human-readable detail. * @returns A failing check. */ -const fail = (reason: string): Check => ({ ok: false, reason }); +const fail = (reason: string, detail?: string): MasumiLockCheck => ({ + ok: false, + reason, + detail, +}); + +/** + * Everything the Masumi lock check needs beyond the requirements and the + * decoded transaction. + */ +export interface MasumiVerifyContext { + /** The decoded Cardano payload (nonce, settlement layer). */ + payload: ExactCardanoPayload; + /** The address that owns the nonce UTXO, i.e. the resolved buyer. */ + payer: string; + /** Live `coinsPerUtxoByte`; when absent the min-UTXO checks are skipped. */ + coinsPerUtxoByte?: bigint; + /** + * Independently validates a registry claim on the selected network. The spec + * requires the asset, seller authorization, metadata, endpoint, network and + * exact price to be checked before a non-empty `agentIdentifier` is honoured. + * + * Without one, a non-empty `agentIdentifier` is **rejected**: accepting an + * unvalidated claim would let anyone assert another registered agent's + * identity and reputation while signing the terms with their own key. + * Unregistered sellers (absent, `null` or empty) are unaffected. + */ + validateRegistryClaim?: MasumiRegistryValidator; + /** The protected resource whose endpoint the registry claim must cover. */ + resource?: ResourceInfo; + /** Explicit application approval for a non-canonical deployment. */ + validateCustomDeployment?: MasumiDeploymentValidator; + /** + * How far past now `external_dispute_unlock_time` may sit. The base + * facilitator deliberately leaves this unset — see + * {@link MasumiAuthorizationOptions.maxDeadlineHorizonMs} — so it cannot + * reject a lock a client with a longer appetite already made. + */ + maxDeadlineHorizonMs?: bigint; +} + +/** + * Options for {@link verifyMasumiAuthorization}. + */ +export interface MasumiAuthorizationOptions { + /** Independently validates a non-empty `agentIdentifier`. */ + validateRegistryClaim?: MasumiRegistryValidator; + /** The protected resource whose endpoint the registry claim must cover. */ + resource?: ResourceInfo; + /** Explicit application approval for a non-canonical deployment. */ + validateCustomDeployment?: MasumiDeploymentValidator; + /** + * The buyer's own content for commitment parts the issuer did not echo, + * keyed by part name. The spec requires the client to recompute every part + * digest "using the issuer's `content` where present and its own request + * bytes where absent" — without this, an omitted part's digest is unchecked. + */ + localCommitmentContent?: Record; + /** + * Rejects a commitment part whose content is neither echoed by the issuer nor + * supplied locally. **Clients MUST set this.** A seller that omits content can + * otherwise invent part digests and an `inputHash`, sign that internally + * consistent manifest, and bind the escrow to a request the buyer never made. + * A facilitator leaves it `false`: it never sees the original request. + */ + requireAllPartContent?: boolean; + /** + * How far past now `external_dispute_unlock_time` may sit. **Buyer policy: a + * client sets it, a verifier does not.** Omitting it skips the horizon check. + * + * Set it to `MASUMI_MAX_DEADLINE_HORIZON_MS` for the default appetite, + * or higher for a counterparty whose long settlement window you accept — + * until `submit_result_time` passes the buyer can recover neither the payment + * nor its collateral. + */ + maxDeadlineHorizonMs?: bigint; +} + +/** + * Validates a Masumi V2 registry claim against the selected network. + * + * @param claim - The registry claim to validate. + * @returns True when the claim is authentic and resolves to the signed price. + */ +export type MasumiRegistryValidator = (claim: { + /** The non-empty `terms.agentIdentifier`. */ + agentIdentifier: string; + /** The seller address the terms are signed by. */ + sellerAddress: string; + /** The x402 Cardano network identifier. */ + network: string; + /** The signed top-level amount. */ + amount: string; + /** The signed top-level asset unit. */ + asset: string; + /** The protected x402 resource whose URL must match the registry endpoint. */ + resource: ResourceInfo; +}) => boolean | Promise; + +/** Explicitly approves one non-canonical Masumi V2 deployment. */ +export type MasumiDeploymentValidator = (claim: { + network: string; + payTo: string; + deployment: MasumiDeployment; +}) => boolean | Promise; /** * Whether two addresses share the same payment (and stake, if any) credential. @@ -41,7 +175,17 @@ const fail = (reason: string): Check => ({ ok: false, reason }); */ function sameCredentials(a: MasumiAddressCredentials, b: MasumiAddressCredentials): boolean { if (a.payment.hash !== b.payment.hash || a.payment.isScript !== b.payment.isScript) return false; - return (a.stake?.hash ?? "") === (b.stake?.hash ?? ""); + if ( + (a.stake?.hash ?? "") !== (b.stake?.hash ?? "") || + (a.stake?.isScript ?? false) !== (b.stake?.isScript ?? false) + ) { + return false; + } + return ( + (a.pointer?.slot ?? -1n) === (b.pointer?.slot ?? -1n) && + (a.pointer?.txIndex ?? -1n) === (b.pointer?.txIndex ?? -1n) && + (a.pointer?.certIndex ?? -1n) === (b.pointer?.certIndex ?? -1n) + ); } /** @@ -49,7 +193,7 @@ function sameCredentials(a: MasumiAddressCredentials, b: MasumiAddressCredential * a declared address must be present in the datum with matching credentials; an * omitted one must be absent (`None`). * - * @param declared - The `extra` return address (bech32), or undefined. + * @param declared - The signed terms' return address (bech32), or undefined. * @param actual - The datum's return-address credentials, or null (`None`). * @returns True when they correspond exactly. */ @@ -62,172 +206,483 @@ function returnAddressMatches( } /** - * Verifies that a payment locks funds into the Masumi `vested_pay` escrow with a - * well-formed `FundsLocked` datum matching the requirements. Only the on-chain - * lock is checked (x402's scope); the post-lock lifecycle is out of scope. + * Whether an address in the datum is one Masumi's own off-chain tooling can + * re-encode. `getPubKeyAddressDatum` in `masumi-payment-service` accepts only an + * enterprise key address or a base address whose **both** credentials are key + * hashes; a script stake credential or a pointer stake reference makes it throw. * - * @param extra - The masumi `extra` block from the canonical requirements. - * @param requirements - The canonical payment requirements. - * @param decoded - The decoded transaction (with output inline datums). - * @param payer - The resolved payer (buyer) address. - * @param coinsPerUtxoByte - Live `coinsPerUtxoByte`; when supplied, the escrow - * output is checked against the post-result min-UTXO. Skipped when absent. - * @returns `{ ok: true }` when the lock is valid, else a precise failure reason. + * That matters after the lock: every later transition rebuilds the continuation + * datum from the decoded one and the validator requires `new_datum.buyer == + * buyer` exactly, so an address form Masumi cannot re-encode leaves the escrow + * unspendable through Masumi tooling. The script payment credential is refused + * for a stronger reason still — `address_to_verification_key` fails on it, so + * the contract itself can never release the funds. + * + * @param credentials - The address credentials taken from the datum. + * @returns A failure detail suffix, or `null` when the address form is accepted. */ -export function verifyMasumiLock( - extra: CardanoExtraMasumi, - requirements: PaymentRequirements, - decoded: DecodedCardanoTransaction, - payer: string, - coinsPerUtxoByte?: bigint, -): Check { - // 1. payTo must equal the deployment's escrow address, which the server - // declares in `extra.contractAddress` (from the purchase). Not defaulted: - // locking to a wrong escrow silently strands the funds. - if (!extra.contractAddress || requirements.payTo !== extra.contractAddress) { - return fail(ERR_MASUMI_CONTRACT_MISMATCH); - } - - // 2. Locate the escrow output paying payTo and carrying an inline datum. - const output = decoded.outputs.find( - o => o.address === requirements.payTo && o.datum !== undefined, - ); - if (!output || output.datum === undefined) { - return fail(ERR_MASUMI_DATUM_MISSING); +function unsupportedAddressForm(credentials: MasumiAddressCredentials): string | null { + if (credentials.payment.isScript) return "script payment credential"; + if (credentials.stake?.isScript) return "script stake credential"; + if (credentials.pointer) return "pointer stake reference"; + return null; +} + +/** + * Checks invariants that make a freshly built V2 datum safe to submit. This is + * shared by the client preflight and facilitator verification so client mode + * cannot broadcast a lock that the facilitator will reject afterwards. + * + * @param view - Parsed fresh-lock datum. + * @param escrowAddress - Derived Masumi V2 escrow address. + * @returns Success, or the first failed invariant. + */ +export function verifyMasumiDatumInvariants( + view: MasumiDatumView, + escrowAddress: string, +): MasumiLockCheck { + if (view.state !== MASUMI_STATE_FUNDS_LOCKED) return fail(ERR_MASUMI_DATUM_INVALID, "state"); + if (view.resultHash !== "") return fail(ERR_MASUMI_DATUM_INVALID, "result_hash"); + if (view.sellerCooldownTime !== 0n || view.buyerCooldownTime !== 0n) { + return fail(ERR_MASUMI_DATUM_INVALID, "cooldown"); } - const datumHex = output.datum; - // The escrow output must NOT carry a reference script — Masumi treats a set - // `reference_script_hash` as a spoofing attempt (FundsOrDatumInvalid). - if (output.hasReferenceScript) { - return fail(ERR_MASUMI_REFERENCE_SCRIPT); + const addressForms: Array<[string, MasumiAddressCredentials | null]> = [ + ["buyer", view.buyer], + ["seller", view.seller], + ["buyer_return_address", view.buyerReturnAddress], + ["seller_return_address", view.sellerReturnAddress], + ]; + for (const [field, credentials] of addressForms) { + if (!credentials) continue; + const unsupported = unsupportedAddressForm(credentials); + if (unsupported) return fail(ERR_MASUMI_DATUM_INVALID, `${field} is a ${unsupported}`); } - const view = parseMasumiLockDatum(datumHex); - if (!view) { - return fail(ERR_MASUMI_DATUM_INVALID); + if (view.referenceSignature.length < 32) { + return fail(ERR_MASUMI_DATUM_INVALID, "reference_signature shorter than 16 bytes"); } - // 3. Structural invariants of a fresh lock. The validator never checks these - // on lock and Masumi validates them off-chain, so any mismatch strands the - // funds on a purchase Masumi then invalidates — reject up front. - if (view.state !== MASUMI_STATE_FUNDS_LOCKED) return fail(ERR_MASUMI_DATUM_INVALID); - if (view.resultHash !== "") return fail(ERR_MASUMI_DATUM_INVALID); - // Fresh lock: both cooldown timers MUST be 0 (a non-zero value is spoofing). - if (view.sellerCooldownTime !== 0n || view.buyerCooldownTime !== 0n) { - return fail(ERR_MASUMI_DATUM_INVALID); + const escrow = addressCredentials(escrowAddress); + const buyerTarget = view.buyerReturnAddress ?? view.buyer; + const sellerTarget = view.sellerReturnAddress ?? view.seller; + if ( + sameCredentials(view.buyer, escrow) || + sameCredentials(view.seller, escrow) || + sameCredentials(buyerTarget, escrow) || + sameCredentials(sellerTarget, escrow) + ) { + return fail(ERR_MASUMI_DATUM_INVALID, "datum address is the escrow"); } - if (view.buyer.payment.isScript || view.seller.payment.isScript) { - return fail(ERR_MASUMI_DATUM_INVALID); + if (sameCredentials(buyerTarget, sellerTarget)) { + return fail(ERR_MASUMI_DATUM_INVALID, "buyer and seller payout targets are equal"); } - // reference_signature: >= 16 bytes (32 hex chars). - if (view.referenceSignature.length < 32) return fail(ERR_MASUMI_DATUM_INVALID); - // Time ordering: pay_by <= submit_result <= unlock <= external_dispute_unlock. + if ( - view.payByTime > view.submitResultTime || - view.submitResultTime > view.unlockTime || - view.unlockTime > view.externalDisputeUnlockTime + !masumiDeadlineIntervalsHold( + view.payByTime, + view.submitResultTime, + view.unlockTime, + view.externalDisputeUnlockTime, + ) ) { - return fail(ERR_MASUMI_DATUM_INVALID); + return fail(ERR_MASUMI_DEADLINE, "deadline intervals below the minimum"); } + return { ok: true }; +} - // 4. Deadline: the tx MUST carry a validity upper bound (TTL) on/before - // pay_by_time, so it cannot settle past the deadline — Masumi flags a lock - // landing after pay_by_time FundsOrDatumInvalid. - if (decoded.ttlSlot === undefined) return fail(ERR_MASUMI_DEADLINE); - if (BigInt(slotToPosixMs(requirements.network, decoded.ttlSlot)) > view.payByTime) { - return fail(ERR_MASUMI_DEADLINE); +/** + * Checks the seller-signed deadline order, and optionally the horizon, before a + * client selects funds. + * + * The interval rule is a protocol rule and always applies. The horizon is + * **buyer policy** and applies only when a caller asks for it. `vested_pay` + * gates the buyer's `WithdrawRefund` on `must_start_after(validity_range, + * submit_result_time)`, so a 402 naming a deadline years out freezes the buyer's + * payment and collateral for that long — but how long a wait is acceptable is + * the payer's risk appetite, not something a third party can decide. + * + * A verifier therefore does not impose one. Once the funds are locked, rejecting + * the payment for a distant deadline unfreezes nothing; it only adds a failed + * settlement to a lock the buyer already chose to make. Worse, a facilitator + * applying its own horizon could reject exactly the payment a client with a + * raised horizon was willing to make — stranding the funds it was meant to + * protect. + * + * @param terms - Schema-validated Masumi terms. + * @param maxDeadlineHorizonMs - How far past now the last deadline may sit; + * omit to skip the horizon check. + * @returns Success, or a deadline failure. + */ +function verifyMasumiTermDeadlines( + terms: CardanoExtraMasumi["terms"], + maxDeadlineHorizonMs?: bigint, +): MasumiLockCheck { + const externalDisputeUnlockTime = BigInt(terms.externalDisputeUnlockTime); + if ( + !masumiDeadlineIntervalsHold( + BigInt(terms.payByTime), + BigInt(terms.submitResultTime), + BigInt(terms.unlockTime), + externalDisputeUnlockTime, + ) + ) { + return fail(ERR_MASUMI_DEADLINE, "deadline intervals below the minimum"); } - - // 5. Value: collateral bounds + asset/amount (mirrors Masumi's - // checkPaymentAmountsMatch). collateral_return_lovelace is denominated in - // lovelace and bounded against the locked lovelace for every asset — a - // collateral above the locked ADA bricks the seller's on-chain spend paths. - const collateral = view.collateralReturnLovelace; if ( - collateral < 0n || - (collateral > 0n && collateral < MASUMI_MIN_COLLATERAL_LOVELACE) || - collateral > output.coin + maxDeadlineHorizonMs !== undefined && + externalDisputeUnlockTime > BigInt(Date.now()) + maxDeadlineHorizonMs ) { - return fail(ERR_MASUMI_COLLATERAL); + return fail(ERR_MASUMI_DEADLINE, "deadlines extend beyond the accepted horizon"); } - const amount = BigInt(requirements.amount); - const assetKey = requirements.asset.toLowerCase(); - if (assetKey === LOVELACE_ASSET) { - // Lovelace: overpayment allowed for min-ada; locked >= amount + collateral. - if (output.coin < amount + collateral) return fail(ERR_MASUMI_ASSET); - } else { - // Native token: exact amount (Masumi forbids token overpayment); its - // structural lovelace covers collateral (above) and min-UTXO (below). - if (output.assets[assetKey] !== amount) return fail(ERR_MASUMI_ASSET); - } - // The escrow output must carry EXACTLY the requested asset set — no extra - // native tokens (Masumi rejects a mismatched token count). Zero tokens for a - // lovelace payment; exactly the one requested token otherwise. - if (Object.keys(output.assets).length !== (assetKey === LOVELACE_ASSET ? 0 : 1)) { - return fail(ERR_MASUMI_ASSET); - } - - // 6. min-UTXO with post-result headroom (when the live coinsPerUtxoByte is - // available): the escrow output must hold enough lovelace that the seller's - // later SubmitResult output (32-byte result_hash + non-zero cooldowns) still - // clears the protocol min-UTXO. Otherwise the seller can never spend. - if (coinsPerUtxoByte !== undefined) { - const nativeTokenCount = Object.keys(output.assets).length; - const requiredMinUtxo = masumiMinUtxoLovelace( - datumHex.length / 2, - nativeTokenCount, - coinsPerUtxoByte, - ); - if (output.coin < requiredMinUtxo) return fail(ERR_MASUMI_MIN_UTXO); + return { ok: true }; +} + +/** + * Verifies the seller authorization carried in `extra`: the request commitment + * recomputes, the reconstructed `termsDigest` is what the seller's COSE + * signature covers, and the compatibility identifier decodes to the same + * values. No transaction is involved — a client runs exactly these checks + * before it signs. + * + * @param extra - The schema-validated masumi `extra` block. + * @param requirements - The canonical payment requirements. + * @param options - Registry validation and buyer-supplied commitment content. + * @returns `{ ok: true }` plus the derived escrow address, else a failure reason. + */ +export async function verifyMasumiAuthorization( + extra: CardanoExtraMasumi, + requirements: PaymentRequirements, + options: MasumiAuthorizationOptions = {}, +): Promise< + | { ok: true; escrowAddress: string; termsDigest: string } + | { ok: false; reason: string; detail?: string } +> { + const { terms, inputCommitment } = extra; + + const deadlines = verifyMasumiTermDeadlines(terms, options.maxDeadlineHorizonMs); + if (!deadlines.ok) return deadlines; + + // Commitment: every part digest must recompute. The issuer echoes the content + // it originates; for a part derived from the buyer's own request bytes the + // buyer supplies it. A part with neither is unverifiable — the client MUST + // refuse it, because a seller free to invent an omitted part's digest is free + // to bind the escrow to a request that was never made. + for (const part of inputCommitment.parts) { + const content = part.content ?? options.localCommitmentContent?.[part.name]; + if (content === undefined) { + if (options.requireAllPartContent) { + return { + ok: false, + reason: ERR_MASUMI_COMMITMENT, + detail: `part ${part.name} carries no content to verify its digest against`, + }; + } + continue; + } + let digest: string; + try { + digest = commitmentPartDigest({ canonicalization: part.canonicalization, content }); + } catch (cause) { + return { + ok: false, + reason: ERR_MASUMI_COMMITMENT, + detail: cause instanceof Error ? cause.message : String(cause), + }; + } + if (digest !== part.digest) { + return { + ok: false, + reason: ERR_MASUMI_COMMITMENT, + detail: `part ${part.name} digest mismatch`, + }; + } + } + if (computeInputHash(inputCommitment) !== inputCommitment.digest) { + return { ok: false, reason: ERR_MASUMI_COMMITMENT, detail: "commitment digest mismatch" }; } - // 7. Field matching against the canonical requirements' extra. - // buyer MUST be the payer; seller MUST be the declared seller. - if (!sameCredentials(view.buyer, addressCredentials(payer))) { - return fail(ERR_MASUMI_DATUM_MISMATCH); + // Escrow address: derived from the deployment parameters, never defaulted + // from `payTo`. Preview has no canonical deployment. + const deployment = resolveMasumiDeployment(requirements.network, extra.deployment); + if (!deployment) { + return { + ok: false, + reason: ERR_MASUMI_DEPLOYMENT, + detail: "network has no canonical deployment; extra.deployment is required", + }; + } + let escrowAddress: string; + try { + escrowAddress = masumiEscrowAddress(requirements.network, deployment); + } catch (cause) { + return { + ok: false, + reason: ERR_MASUMI_DEPLOYMENT, + detail: cause instanceof Error ? cause.message : String(cause), + }; } - if (!sameCredentials(view.seller, addressCredentials(extra.sellerAddress))) { - return fail(ERR_MASUMI_DATUM_MISMATCH); + if (escrowAddress !== requirements.payTo) { + return { + ok: false, + reason: ERR_MASUMI_DEPLOYMENT, + detail: `derived escrow ${escrowAddress} does not equal payTo`, + }; } - // Return addresses must match EXACTLY: declared in extra -> datum `Some(match)`, - // omitted -> datum `None`. Masumi compares these against the purchase, so a - // return address it doesn't expect (or a missing one it does) is rejected. + const termsDigest = computeTermsDigest(buildSignedTerms(extra, requirements)); if ( - !returnAddressMatches(extra.buyerReturnAddress, view.buyerReturnAddress) || - !returnAddressMatches(extra.sellerReturnAddress, view.sellerReturnAddress) + !verifySellerTermsSignature( + extra.referenceKey, + extra.referenceSignature, + terms.sellerAddress, + termsDigest, + ) ) { - return fail(ERR_MASUMI_DATUM_MISMATCH); - } - // Server-declared datum fields, when present, MUST match the datum. Fields the - // server omits are client-filled (random/default) and only invariant-checked. - const mismatches: Array<[string | undefined, string]> = [ - [extra.referenceKey, view.referenceKey], - [extra.referenceSignature, view.referenceSignature], - [extra.sellerNonce, view.sellerNonce], - [extra.identifierFromPurchaser, view.buyerNonce], - [extra.agentIdentifier, view.agentIdentifier], - [extra.inputHash, view.inputHash], - ]; - for (const [declared, actual] of mismatches) { - if (declared !== undefined && declared.toLowerCase() !== actual) { - return fail(ERR_MASUMI_DATUM_MISMATCH); + return { ok: false, reason: ERR_MASUMI_SELLER_SIGNATURE }; + } + + // Run application callbacks only after the local seller signature check. + if (extra.deployment) { + if (!options.validateCustomDeployment) { + return { + ok: false, + reason: ERR_MASUMI_DEPLOYMENT, + detail: "custom deployment requires explicit application approval", + }; + } + if ( + !(await options.validateCustomDeployment({ + network: requirements.network, + payTo: requirements.payTo, + deployment: extra.deployment, + })) + ) { + return { + ok: false, + reason: ERR_MASUMI_DEPLOYMENT, + detail: "custom deployment was not approved", + }; } } - const timeMismatch: Array<[string | undefined, bigint]> = [ - [extra.payByTime, view.payByTime], - [extra.submitResultTime, view.submitResultTime], - [extra.unlockTime, view.unlockTime], - [extra.externalDisputeUnlockTime, view.externalDisputeUnlockTime], - ]; - for (const [declared, actual] of timeMismatch) { - if (declared !== undefined && BigInt(declared) !== actual) { - return fail(ERR_MASUMI_DATUM_MISMATCH); + + const agentIdentifier = typeof terms.agentIdentifier === "string" ? terms.agentIdentifier : ""; + if (agentIdentifier.length > 0) { + if (!agentIdentifier.startsWith(MASUMI_REGISTRY_POLICY_ID)) { + return { + ok: false, + reason: ERR_MASUMI_AGENT_IDENTIFIER, + detail: "agentIdentifier does not carry the Masumi V2 registry policy id", + }; + } + if (!options.validateRegistryClaim) { + return { + ok: false, + reason: ERR_MASUMI_AGENT_IDENTIFIER, + detail: "registry claims require an independent on-network validator", + }; + } + if (!options.resource) { + return { + ok: false, + reason: ERR_MASUMI_AGENT_IDENTIFIER, + detail: "registry claims require the protected resource for endpoint validation", + }; + } + if ( + !(await options.validateRegistryClaim({ + agentIdentifier, + sellerAddress: terms.sellerAddress, + network: requirements.network, + amount: requirements.amount, + asset: requirements.asset, + resource: options.resource, + })) + ) { + return { + ok: false, + reason: ERR_MASUMI_AGENT_IDENTIFIER, + detail: "registry validation rejected the claim", + }; } } + + const decoded = decodeBlockchainIdentifier(extra.blockchainIdentifier); if ( - extra.collateralReturnLovelace !== undefined && - BigInt(extra.collateralReturnLovelace) !== view.collateralReturnLovelace + !decoded || + decoded.sellerNonce !== terms.sellerNonce || + decoded.agentIdentifier !== agentIdentifier || + decoded.buyerNonce !== terms.buyerNonce || + decoded.referenceSignature !== extra.referenceSignature || + decoded.referenceKey !== extra.referenceKey || + decoded.contractAddress !== requirements.payTo ) { - return fail(ERR_MASUMI_DATUM_MISMATCH); + return { ok: false, reason: ERR_MASUMI_IDENTIFIER }; + } + + return { ok: true, escrowAddress, termsDigest }; +} + +/** + * Verifies that a payment locks funds into the Masumi `vested_pay` escrow with a + * well-formed `FundsLocked` datum matching the seller-signed terms. Only the + * on-chain lock is checked (x402's scope); the post-lock lifecycle is governed + * by the contract in later transactions. + * + * Because `vested_pay` only runs on spend, a malformed datum is not rejected at + * lock time — it silently strands the funds — so every invariant below is + * enforced here rather than left to the validator. + * + * @param rawExtra - The `extra` block from the canonical requirements. + * @param requirements - The canonical payment requirements. + * @param decoded - The decoded transaction (with output inline datums). + * @param context - Payload, resolved payer and live protocol parameters. + * @returns `{ ok: true }` when the lock is valid, else a precise failure reason. + */ +export async function verifyMasumiLock( + rawExtra: unknown, + requirements: PaymentRequirements, + decoded: DecodedCardanoTransaction, + context: MasumiVerifyContext, +): Promise { + const schema = validateMasumiExtra(rawExtra, requirements.network); + if (!schema.ok) return fail(ERR_MASUMI_SCHEMA, schema.detail); + const extra = schema.extra; + const terms = extra.terms; + + const authorization = await verifyMasumiAuthorization(extra, requirements, { + ...(context.validateRegistryClaim + ? { validateRegistryClaim: context.validateRegistryClaim } + : {}), + ...(context.resource ? { resource: context.resource } : {}), + ...(context.validateCustomDeployment + ? { validateCustomDeployment: context.validateCustomDeployment } + : {}), + ...(context.maxDeadlineHorizonMs !== undefined + ? { maxDeadlineHorizonMs: context.maxDeadlineHorizonMs } + : {}), + }); + if (!authorization.ok) return authorization; + const escrowAddress = authorization.escrowAddress; + + // Settlement layer: required for masumi, and admitted by the signed policy. + const settlementLayer = context.payload.settlementLayer; + if (settlementLayer === undefined) { + return fail(ERR_SETTLEMENT_LAYER_MISMATCH, "payload.settlementLayer is required for masumi"); + } + if (terms.settlementPolicy !== "auto" && terms.settlementPolicy !== settlementLayer) { + return fail(ERR_SETTLEMENT_LAYER_MISMATCH); + } + // Hydra needs verified Init state, head parameters, a seller-participant + // binding and `SnapshotConfirmed` evidence from the selected head. This + // implementation has none of that, and authenticating a Hydra payment against + // L1 evidence would be a lie, so Hydra is refused outright. + if (settlementLayer === "hydra") { + return fail(ERR_SETTLEMENT_LAYER_UNSUPPORTED, "Hydra settlement is not implemented"); + } + + // Exactly one escrow output, carrying an inline datum and no reference script. + const escrowOutputs = decoded.outputs.filter(o => o.address === escrowAddress); + if (escrowOutputs.length !== 1) return fail(ERR_MASUMI_ESCROW_OUTPUT_COUNT); + const output = escrowOutputs[0]; + if (output.datum === undefined) return fail(ERR_MASUMI_DATUM_MISSING); + // Masumi treats a set `reference_script_hash` as a spoofing attempt. + if (output.hasReferenceScript) return fail(ERR_MASUMI_REFERENCE_SCRIPT); + + const datumHex = output.datum; + const view = parseMasumiLockDatum(datumHex); + if (!view) return fail(ERR_MASUMI_DATUM_INVALID, "datum does not match masumi.vested_pay.v2"); + + const datumInvariants = verifyMasumiDatumInvariants(view, escrowAddress); + if (!datumInvariants.ok) return datumInvariants; + // The tx MUST carry a validity upper bound on/before pay_by_time, so the lock + // cannot settle past the deadline. + if (decoded.ttlSlot === undefined) return fail(ERR_MASUMI_DEADLINE, "no validity upper bound"); + if (BigInt(slotToPosixMs(requirements.network, decoded.ttlSlot)) > view.payByTime) { + return fail(ERR_MASUMI_DEADLINE, "TTL is after pay_by_time"); + } + + // Buyer identity: the datum's payment credential must control the nonce input + // and have witnessed the transaction. Only the payment credential is compared + // — the buyer may pick any stake part for its own address. + const payerCredentials = addressCredentials(context.payer); + if (view.buyer.payment.hash !== payerCredentials.payment.hash) { + return fail(ERR_MASUMI_DATUM_MISMATCH, "buyer does not control the nonce input"); + } + if (!decoded.vkeyHashes.includes(view.buyer.payment.hash)) { + return fail(ERR_MASUMI_DATUM_MISMATCH, "no witness for the buyer credential"); + } + + // Seller-side datum fields match the signed terms exactly. `buyer_return_address` + // is buyer-chosen and deliberately not matched. + if (!sameCredentials(view.seller, addressCredentials(terms.sellerAddress))) { + return fail(ERR_MASUMI_DATUM_MISMATCH, "seller"); + } + if (!returnAddressMatches(terms.sellerReturnAddress, view.sellerReturnAddress)) { + return fail(ERR_MASUMI_DATUM_MISMATCH, "seller_return_address"); + } + const agentIdentifier = typeof terms.agentIdentifier === "string" ? terms.agentIdentifier : ""; + const byteFields: Array<[string, string, string]> = [ + ["reference_key", extra.referenceKey, view.referenceKey], + ["reference_signature", extra.referenceSignature, view.referenceSignature], + ["seller_nonce", terms.sellerNonce, view.sellerNonce], + ["buyer_nonce", terms.buyerNonce, view.buyerNonce], + ["agent_identifier", agentIdentifier, view.agentIdentifier], + ["input_hash", terms.inputHash, view.inputHash], + ]; + for (const [field, declared, actual] of byteFields) { + if (declared.toLowerCase() !== actual) return fail(ERR_MASUMI_DATUM_MISMATCH, field); + } + const timeFields: Array<[string, string, bigint]> = [ + ["pay_by_time", terms.payByTime, view.payByTime], + ["submit_result_time", terms.submitResultTime, view.submitResultTime], + ["unlock_time", terms.unlockTime, view.unlockTime], + [ + "external_dispute_unlock_time", + terms.externalDisputeUnlockTime, + view.externalDisputeUnlockTime, + ], + ]; + for (const [field, declared, actual] of timeFields) { + if (BigInt(declared) !== actual) return fail(ERR_MASUMI_DATUM_MISMATCH, field); + } + + // Value: `lockedLovelace = requestedLovelace + collateral_return_lovelace` + // exactly, with the collateral either zero or at/above the Masumi floor. + const amount = BigInt(requirements.amount); + const assetKey = requirements.asset.toLowerCase(); + const isLovelace = assetKey === LOVELACE_ASSET; + const requestedLovelace = isLovelace ? amount : 0n; + const collateral = view.collateralReturnLovelace; + if (collateral < 0n || (collateral > 0n && collateral < MASUMI_MIN_COLLATERAL_LOVELACE)) { + return fail(ERR_MASUMI_COLLATERAL, `collateral ${collateral} below the floor`); + } + if (output.coin !== requestedLovelace + collateral) { + return fail( + ERR_MASUMI_COLLATERAL, + `locked ${output.coin} lovelace, expected ${requestedLovelace + collateral}`, + ); + } + if (!isLovelace && output.assets[assetKey] !== amount) { + return fail(ERR_MASUMI_ASSET, "native token amount is not exact"); + } + // The escrow output carries EXACTLY the requested asset set. + if (Object.keys(output.assets).length !== (isLovelace ? 0 : 1)) { + return fail(ERR_MASUMI_ASSET, "escrow output carries extra native tokens"); + } + + // min-UTXO with post-`SubmitResult` headroom: the escrow must still clear the + // protocol minimum once `result_hash` is 32 bytes and the cooldowns non-zero, + // otherwise the seller can never spend it. + if (context.coinsPerUtxoByte !== undefined) { + const requiredMinUtxo = masumiMinUtxoLovelace( + datumHex.length / 2, + Object.keys(output.assets).length, + context.coinsPerUtxoByte, + ); + if (output.coin < requiredMinUtxo) { + return fail( + ERR_MASUMI_MIN_UTXO, + `locked ${output.coin}, post-result minimum ${requiredMinUtxo}`, + ); + } } return { ok: true }; diff --git a/typescript/packages/mechanisms/cardano/src/exact/script/datum.ts b/typescript/packages/mechanisms/cardano/src/exact/script/datum.ts index 1196d515f9..3dc6a69c4b 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/script/datum.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/script/datum.ts @@ -1,6 +1,7 @@ import { Data, InlineDatum } from "@evolution-sdk/evolution"; import type { CardanoExtraScript } from "../../types"; +import { MAX_CARDANO_DATUM_BYTES } from "../../limits"; /** * Builds the inline datum a script payment attaches to its `payTo` output from @@ -24,7 +25,12 @@ export function buildScriptDatumInline( if (extra.datum === undefined) { return undefined; } - if (typeof extra.datum !== "string" || extra.datum.length === 0) { + if ( + typeof extra.datum !== "string" || + extra.datum.length === 0 || + extra.datum.length % 2 !== 0 || + extra.datum.length / 2 > MAX_CARDANO_DATUM_BYTES + ) { throw new Error('Cardano script payment "datum" must be non-empty CBOR hex'); } let data: Data.Data; diff --git a/typescript/packages/mechanisms/cardano/src/exact/server/index.ts b/typescript/packages/mechanisms/cardano/src/exact/server/index.ts index 93aa7bb56d..3966a5ab08 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/server/index.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/server/index.ts @@ -1 +1,10 @@ export { ExactCardanoScheme } from "./scheme"; +export type { CardanoReplayBindingContext, ExactCardanoServerConfig } from "./scheme"; +export { InMemoryCardanoOperationStore } from "../../idempotency"; +export type { + CardanoOperationClaim, + CardanoOperationClaimResult, + CardanoOperationStore, + CardanoStoredResponse, + InMemoryCardanoOperationStoreOptions, +} from "../../idempotency"; diff --git a/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts b/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts index 434f8f24bf..154e3e9fd8 100644 --- a/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts +++ b/typescript/packages/mechanisms/cardano/src/exact/server/scheme.ts @@ -5,17 +5,210 @@ import type { Network, PaymentRequirements, Price, + SchemePaymentRequiredContext, SchemeNetworkServer, + SchemeServerHooks, SupportedKind, } from "@x402/core/types"; +import { sha256 } from "@noble/hashes/sha2.js"; import { convertToTokenAmount, numberToDecimalString, parseMoneyString } from "@x402/core/utils"; +import { randomBytes } from "node:crypto"; import { - CARDANO_ASSET_REGEX, + ASSET_TRANSFER_METHOD_DEFAULT, + ASSET_TRANSFER_METHOD_MASUMI, + CANONICAL_CARDANO_ASSET_REGEX, + ERR_SETTLEMENT_DEFINITIVELY_REJECTED, getDefaultUsdmAsset, isCardanoNetwork, + POSITIVE_CANONICAL_AMOUNT_REGEX, SCHEME_EXACT, + SUBMISSION_POLICY_EITHER, USDM_DEFAULT_DECIMALS, } from "../../constants"; +import { + InMemoryCardanoOperationStore, + type CardanoOperationStore, + type CardanoStoredResponse, + type InMemoryCardanoOperationStoreOptions, +} from "../../idempotency"; +import { DEFAULT_CARDANO_REPLAY_BODY_BYTES } from "../../limits"; +import { normalizeSubmissionMode, resolveCardanoPolicies } from "../../policy"; +import type { CardanoExtraMasumi } from "../../types"; +import { decodeCardanoTransaction } from "../../utils"; +import { buildSignedTerms, computeTermsDigest } from "../masumi/digests"; +import { jcs } from "../masumi/jcs"; +import { validateMasumiExtra } from "../masumi/schema"; + +/** Cached protected-handler result bound to one payment and request. */ +interface HandlerReplayOwner { + key: string; + ownerToken: string; +} + +/** HTTP-like transport fields used without coupling the mechanism to one adapter. */ +interface ReplayTransportContext { + request?: { + method?: string; + adapter?: { + getMethod?(): string; + getUrl?(): string; + getHeader?(name: string): string | undefined; + getBody?(): unknown | Promise; + getRawBody?(): Uint8Array | undefined | Promise; + }; + }; + responseBody?: Buffer; + responseHeaders?: Record; + responseStatus?: number; +} + +/** Headers that describe the buffered transfer, not the reusable representation. */ +const NON_REPLAYABLE_RESPONSE_HEADERS = new Set([ + "authentication-info", + "cache-control", + "content-encoding", + "content-length", + "content-md5", + "digest", + "payment-response", + "proxy-authenticate", + "set-cookie", + "settlement-overrides", + "transfer-encoding", + "www-authenticate", +]); + +const DEFAULT_REQUEST_BINDING_HEADERS = ["authorization", "cookie", "x-api-key"] as const; +const CARDANO_REPLAY_EXTENSION_KEY = "cardanoReplayProtection"; +const REPLAY_CHALLENGE_HEX = /^[0-9a-f]{64}$/; +const DEFAULT_REPLAY_CHALLENGE_TTL_MS = 15 * 60 * 1000; + +/** Request data available to an application-specific replay-binding callback. */ +export interface CardanoReplayBindingContext { + method: string; + url: string; + contentType: string; + body: unknown; + rawBody?: Uint8Array; + getHeader(name: string): string | undefined; +} + +/** Cardano resource-server replay configuration. */ +export interface ExactCardanoServerConfig { + /** + * Atomic durable idempotency store shared by every worker and deployment. + * Replay tombstones must survive process restarts. + */ + operationStore?: CardanoOperationStore; + /** + * Capacity settings for an explicitly selected process-local store. Supplying + * this opts into volatile replay state and is suitable only for tests and + * disposable development servers. + */ + inMemoryStore?: InMemoryCardanoOperationStoreOptions; + /** + * Returns the authenticated principal/tenant binding for a paid request. + * Authentication must run before the x402 middleware. Header values remain + * part of the request fingerprint, but never prove authentication by + * themselves when this callback is absent. + */ + requestBinding?: (context: CardanoReplayBindingContext) => string | Promise; + /** + * Returns exact bytes to bind when an adapter cannot expose `getRawBody()` and + * its parsed body is not plain JSON. The default rejects unsupported values. + */ + requestBodyBytes?: ( + context: CardanoReplayBindingContext, + ) => Uint8Array | string | Promise; + /** Maximum raw or canonical request-body bytes covered by one fingerprint. */ + maxRequestBodyBytes?: number; + /** Minimum lifetime of an opaque challenge issued in a 402 response. */ + replayChallengeTtlMs?: number; +} + +/** + * Returns lowercase hexadecimal bytes. + * + * @param bytes - Bytes to encode. + * @returns Lowercase hexadecimal string. + */ +function bytesToHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex").toLowerCase(); +} + +/** + * Validates one positive safe-integer configuration value. + * + * @param value - Candidate setting. + * @param name - Setting name used in the error. + * @returns The validated value. + */ +function positiveSafeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive safe integer`); + } + return value; +} + +/** Maximum nesting accepted by the default parsed-JSON replay serializer. */ +const MAX_REPLAY_JSON_DEPTH = 64; +/** Maximum values visited by the default parsed-JSON replay serializer. */ +const MAX_REPLAY_JSON_VALUES = 100_000; + +/** + * Rejects values whose JSON meaning is ambiguous across adapters. This also + * prevents cycles and pathological nesting before JCS recursion begins. + * + * @param root - Parsed request body. + * @param maxBytes - Maximum aggregate UTF-8 bytes. + */ +function assertPlainJson(root: unknown, maxBytes: number): void { + const pending: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }]; + const seen = new WeakSet(); + let visited = 0; + let bytes = 0; + + while (pending.length > 0) { + const { value, depth } = pending.pop()!; + visited++; + if (visited > MAX_REPLAY_JSON_VALUES) { + throw new Error("request body exceeds the JSON value limit"); + } + if (depth > MAX_REPLAY_JSON_DEPTH) { + throw new Error("request body exceeds the JSON nesting limit"); + } + if (typeof value === "string") { + bytes += Buffer.byteLength(value, "utf8"); + if (bytes > maxBytes) throw new Error("request body exceeds the byte limit"); + continue; + } + if ( + value === null || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + continue; + } + if (typeof value !== "object") { + throw new Error(`request body contains unsupported ${typeof value} value`); + } + if (seen.has(value)) throw new Error("request body contains a cycle"); + seen.add(value); + if (Array.isArray(value)) { + for (const item of value) pending.push({ value: item, depth: depth + 1 }); + continue; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error("request body must contain only plain JSON objects"); + } + for (const [key, item] of Object.entries(value as Record)) { + bytes += Buffer.byteLength(key, "utf8"); + if (bytes > maxBytes) throw new Error("request body exceeds the byte limit"); + pending.push({ value: item, depth: depth + 1 }); + } + } +} /** * Cardano server-side implementation for the Exact scheme. @@ -26,12 +219,121 @@ import { */ export class ExactCardanoScheme implements SchemeNetworkServer { readonly scheme = SCHEME_EXACT; + readonly requireMatchingPayloadResource = true; + readonly schemeHooks: SchemeServerHooks; private readonly moneyParsers: MoneyParser[] = []; + private readonly operationStore: CardanoOperationStore; + private readonly requestBinding?: ExactCardanoServerConfig["requestBinding"]; + private readonly requestBodyBytes?: ExactCardanoServerConfig["requestBodyBytes"]; + private readonly maxRequestBodyBytes: number; + private readonly replayChallengeTtlMs: number; + private readonly replayOwners = new WeakMap(); + + /** + * Creates a server scheme with its Cardano replay lifecycle hooks. + * + * @param config - Replay persistence, capacity and requester-binding options. + */ + constructor(config: ExactCardanoServerConfig = {}) { + if (config.operationStore) { + this.operationStore = config.operationStore; + } else if (config.inMemoryStore) { + this.operationStore = new InMemoryCardanoOperationStore(config.inMemoryStore); + } else { + throw new Error( + "Cardano resource servers require a durable operationStore; pass inMemoryStore explicitly only for tests or disposable development", + ); + } + this.requestBinding = config.requestBinding; + this.requestBodyBytes = config.requestBodyBytes; + this.maxRequestBodyBytes = positiveSafeInteger( + config.maxRequestBodyBytes ?? DEFAULT_CARDANO_REPLAY_BODY_BYTES, + "maxRequestBodyBytes", + ); + this.replayChallengeTtlMs = positiveSafeInteger( + config.replayChallengeTtlMs ?? DEFAULT_REPLAY_CHALLENGE_TTL_MS, + "replayChallengeTtlMs", + ); + this.schemeHooks = { + onAfterVerify: async context => { + try { + return await this.claimProtectedOperation(context); + } catch { + return { + abort: true, + reason: "payment_replay_store_unavailable", + message: "Cardano replay protection is unavailable", + status: 503, + }; + } + }, + onAfterSettle: async context => this.storeProtectedResult(context), + onSettleFailure: async context => this.storeProtectedResult(context), + onVerifiedPaymentCanceled: async context => this.cancelProtectedOperation(context), + }; + } + + /** + * Adds an opaque request-bound challenge to each Cardano 402. Normal x402 v2 + * clients echo top-level extensions in the paid retry. A fresh challenge is + * issued for every unpaid response, so another caller cannot fetch the same + * capability by repeating an anonymous request. + * + * @param context - Payment-required response being built. + */ + async enrichPaymentRequiredResponse(context: SchemePaymentRequiredContext): Promise { + const transport = this.asReplayTransport(context.transportContext); + if (!transport) return; + if (!this.replayOwner(transport)) { + throw new Error("Cardano replay protection requires a stable request adapter"); + } + + const binding = await this.requestFingerprint(transport); + const requirementsFingerprint = this.requirementsFingerprint(context.requirement); + const echoedChallenge = this.replayChallenge( + context.paymentPayload?.extensions, + requirementsFingerprint, + ); + const canReuseEchoedChallenge = echoedChallenge + ? await this.operationStore.validateChallenge(echoedChallenge, { + fingerprint: binding.fingerprint, + requirementsFingerprint, + }) + : false; + let challenge = canReuseEchoedChallenge ? echoedChallenge : undefined; + if (!challenge) { + const timeoutMs = context.requirement.maxTimeoutSeconds * 1000; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) { + throw new Error("Cardano maxTimeoutSeconds cannot produce a safe challenge lifetime"); + } + const lifetime = Math.max(this.replayChallengeTtlMs, timeoutMs); + const expiresAt = Date.now() + lifetime; + if (!Number.isSafeInteger(expiresAt)) { + throw new Error("Cardano replay challenge expiry exceeds the safe integer range"); + } + const issued = await this.operationStore.issueChallenge({ + fingerprint: binding.fingerprint, + requirementsFingerprint, + expiresAt, + }); + if (issued.status === "capacity-exceeded") { + throw new Error("Cardano replay challenge store is at capacity"); + } + challenge = issued.challenge; + } + + const current = this.replayExtension(context.paymentRequiredResponse.extensions); + context.paymentRequiredResponse.extensions = { + ...context.paymentRequiredResponse.extensions, + [CARDANO_REPLAY_EXTENSION_KEY]: { + challenges: { ...current, [requirementsFingerprint]: challenge }, + }, + }; + } /** * Registers a custom Money parser. Parsers are tried in registration order; - * the first non-null result wins. Returns `null` to defer to the next - * parser. + * the first non-null result wins. Returns `null` to defer to the next parser. * * @param parser - The parser to register. * @returns This instance for chaining. @@ -42,9 +344,8 @@ export class ExactCardanoScheme implements SchemeNetworkServer { } /** - * Converts a price into an AssetAmount. AssetAmount inputs are passed - * through (after asset validation); Money inputs are parsed via the parser - * chain falling back to the default USDM conversion. + * Converts a price into an AssetAmount. AssetAmount inputs are passed through + * after validation. Money inputs use the parser chain, then USDM conversion. * * @param price - The price to parse. * @param network - The Cardano network identifier. @@ -55,29 +356,28 @@ export class ExactCardanoScheme implements SchemeNetworkServer { if (!price.asset) { throw new Error(`Asset unit must be specified for AssetAmount on network ${network}`); } - if (!CARDANO_ASSET_REGEX.test(price.asset)) { - throw new Error(`Invalid Cardano asset unit: ${price.asset}`); - } - return { amount: price.amount, asset: price.asset, extra: price.extra ?? {} }; + return this.validateAssetAmount( + { amount: price.amount, asset: price.asset, extra: price.extra ?? {} }, + "AssetAmount", + ); } const decimal = this.parseMoneyToDecimal(price as Money); for (const parser of this.moneyParsers) { const result = await parser(decimal, network); if (result !== null) { - if (!CARDANO_ASSET_REGEX.test(result.asset)) { - throw new Error(`Custom money parser returned invalid Cardano asset: ${result.asset}`); - } - return result; + return this.validateAssetAmount(result, "Custom money parser result"); } } - return this.defaultMoneyConversion(decimal, network); + return this.validateAssetAmount( + this.defaultMoneyConversion(decimal, network), + "Default money conversion", + ); } /** - * Returns the decimal precision for the supplied asset. Currently we assume - * USDM/USDC decimals (6) when the asset matches the default; integrators - * should subclass to provide custom decimals for other tokens. + * Returns the decimal precision for the supplied asset. The default is six; + * integrators can subclass this scheme for another token precision. * * @param _asset - The asset unit string. * @param _network - The Cardano network identifier. @@ -90,16 +390,15 @@ export class ExactCardanoScheme implements SchemeNetworkServer { } /** - * Enhances payment requirements before they are returned to the client. - * The base implementation passes the supportedKind extra through so any - * facilitator-supplied metadata reaches the client. + * Leaves requirement extras unchanged. `/supported` extras advertise + * capabilities; they are not payment semantics and Masumi extras are closed. * * @param paymentRequirements - The base payment requirements. * @param supportedKind - The matching SupportedKind. - * @param extensionKeys - The list of facilitator extension keys. - * @returns Promise resolving to enhanced payment requirements. + * @param extensionKeys - The facilitator extension keys. + * @returns The unchanged payment requirements. */ - enhancePaymentRequirements( + async enhancePaymentRequirements( paymentRequirements: PaymentRequirements, supportedKind: SupportedKind, extensionKeys: string[], @@ -108,11 +407,543 @@ export class ExactCardanoScheme implements SchemeNetworkServer { if (!isCardanoNetwork(supportedKind.network)) { throw new Error(`Unsupported Cardano network: ${supportedKind.network}`); } - const extra: Record = { - ...(supportedKind.extra ?? {}), - ...(paymentRequirements.extra ?? {}), + this.assertFacilitatorSupportsRequirements(paymentRequirements, supportedKind); + return paymentRequirements; + } + + /** + * Checks selected Cardano payment semantics against the facilitator's + * advertised capabilities without copying capability metadata into the 402. + * + * The check is all-or-nothing on purpose. A facilitator that publishes no + * `extra` at all has told us nothing, so there is nothing to check and the + * requirements pass. One that publishes an `extra` has claimed to describe + * itself, and every capability this scheme selects must then appear in it — + * a half-filled advertisement is treated as a rejection rather than as + * permission, because the alternative is serving a 402 nobody can settle. + * + * @param requirements - Requirements about to be served. + * @param supportedKind - Matching facilitator capability advertisement. + */ + private assertFacilitatorSupportsRequirements( + requirements: PaymentRequirements, + supportedKind: SupportedKind, + ): void { + const advertised = supportedKind.extra; + if (advertised === undefined || advertised === null) return; + if (typeof advertised !== "object" || Array.isArray(advertised)) { + throw new Error("Cardano facilitator advertised a malformed capability block"); + } + + const capabilities = advertised as Record; + const method = requirements.extra?.assetTransferMethod ?? ASSET_TRANSFER_METHOD_DEFAULT; + const methods = capabilities.assetTransferMethods; + if (!Array.isArray(methods)) { + throw new Error("Cardano facilitator did not advertise assetTransferMethods"); + } + if (!methods.includes(method)) { + throw new Error(`Cardano facilitator does not support assetTransferMethod ${String(method)}`); + } + + const policies = resolveCardanoPolicies(requirements.extra); + if (!policies) { + throw new Error("Cardano requirements carry an invalid submission/confirmation policy"); + } + const selectedModes = + policies.submissionPolicy === SUBMISSION_POLICY_EITHER + ? (["server", "client"] as const) + : ([policies.submissionPolicy] as const); + const advertisedModes = capabilities.submissionModes; + if (!Array.isArray(advertisedModes)) { + throw new Error("Cardano facilitator did not advertise submissionModes"); + } + const confirmationRanges = capabilities.l1Confirmations; + if ( + !confirmationRanges || + typeof confirmationRanges !== "object" || + Array.isArray(confirmationRanges) + ) { + throw new Error("Cardano facilitator did not advertise l1Confirmations"); + } + for (const mode of selectedModes) { + if (!advertisedModes.includes(mode)) { + throw new Error(`Cardano facilitator does not support ${mode} submission`); + } + const range = (confirmationRanges as Record)[mode]; + if (!range || typeof range !== "object" || Array.isArray(range)) { + throw new Error( + `Cardano facilitator did not advertise an L1 confirmation range for ${mode}`, + ); + } + const minimum = (range as Record).minimum; + const maximum = (range as Record).maximum; + if ( + typeof minimum !== "number" || + !Number.isInteger(minimum) || + typeof maximum !== "number" || + !Number.isInteger(maximum) || + policies.confirmationPolicy.l1Confirmations < minimum || + policies.confirmationPolicy.l1Confirmations > maximum + ) { + throw new Error( + `Cardano facilitator ${mode} confirmation range does not include ${policies.confirmationPolicy.l1Confirmations}`, + ); + } + } + + if (method === ASSET_TRANSFER_METHOD_MASUMI) { + // `extra` is still the raw wire object here, so `settlementPolicy` comes + // from the schema check rather than from an unchecked cast. The schema + // requires the field, which is why there is no default to apply. + const schema = validateMasumiExtra(requirements.extra, requirements.network); + if (!schema.ok) { + throw new Error(`Cardano Masumi requirements are invalid: ${schema.detail}`); + } + const settlementPolicy = schema.extra.terms.settlementPolicy; + const advertisedLayers = capabilities.settlementLayers; + if (!Array.isArray(advertisedLayers)) { + throw new Error("Cardano facilitator did not advertise settlementLayers"); + } + // `auto` lets the buyer choose, and the only layer this scheme can + // actually authenticate is L1 — a Hydra payload is refused outright in + // `verifyMasumiLock`. Accepting `auto` against a Hydra-only facilitator + // would serve a 402 whose every payment is rejected at verification. + // An explicit `hydra` policy still defers to the advertisement, so a + // subclass that does implement Hydra keeps working. + const supported = + settlementPolicy === "auto" + ? advertisedLayers.includes("l1") + : advertisedLayers.includes(settlementPolicy); + if (!supported) { + throw new Error( + `Cardano facilitator does not support Masumi ${String(settlementPolicy)} settlement`, + ); + } + } + } + + /** + * Claims a canonical transaction (or Masumi terms digest) before the handler + * runs. Identical paid retries receive the stored result; concurrent or + * mismatched uses cannot execute the handler again. + * + * @param context - Core after-verify hook context. + * @returns A skip or abort directive when a prior claim exists. + */ + private async claimProtectedOperation( + context: Parameters>[0], + ): Promise< + | void + | { skipHandler: true; response: CardanoStoredResponse } + | { abort: true; reason: string; message: string; status?: number } + > { + if (!context.result.isValid) return; + const transport = this.asReplayTransport(context.transportContext); + if (!transport) return; + const replayOwner = this.replayOwner(transport); + if (!replayOwner) { + return { + abort: true, + reason: "payment_replay_store_unavailable", + message: "Cardano replay protection requires a stable request adapter", + status: 503, + }; + } + + let identity: { key: string; txHash: string }; + try { + identity = this.paymentIdentity(context.paymentPayload, context.requirements); + } catch (cause) { + return { + abort: true, + reason: "invalid_exact_cardano_payload", + message: cause instanceof Error ? cause.message : String(cause), + }; + } + const binding = await this.requestFingerprint(transport); + const requirementsFingerprint = this.requirementsFingerprint(context.requirements); + const replayChallenge = this.replayChallenge( + context.paymentPayload.extensions, + requirementsFingerprint, + ); + const submissionMode = normalizeSubmissionMode( + (context.paymentPayload.payload as { submissionMode?: unknown }).submissionMode, + ); + if (submissionMode === "client" && !binding.isRequesterAuthenticated) { + return { + abort: true, + reason: "payment_replay_binding_required", + message: "client-submitted Cardano payments require an authenticated requester binding", + status: 403, + }; + } + const ownerToken = randomBytes(16).toString("hex"); + const claim = await this.operationStore.claim({ + ...identity, + fingerprint: binding.fingerprint, + requirementsFingerprint, + ...(replayChallenge ? { replayChallenge } : {}), + requireReplayChallenge: submissionMode === "client" || !binding.isRequesterAuthenticated, + ownerToken, + }); + switch (claim.status) { + case "transaction-conflict": + return { + abort: true, + reason: "duplicate_settlement", + message: "payment terms are already bound to a different Cardano transaction", + status: 409, + }; + case "request-conflict": + return { + abort: true, + reason: "payment_replay_conflict", + message: "Cardano transaction is already bound to a different protected request", + status: 409, + }; + case "completed": + return { + skipHandler: true, + response: { + ...claim.response, + headers: { ...claim.response.headers }, + body: Buffer.isBuffer(claim.response.body) + ? Buffer.from(claim.response.body) + : claim.response.body, + }, + }; + case "completed-without-response": + return { + abort: true, + reason: "payment_replay_response_unavailable", + message: "the paid operation completed but its response was too large to retain", + status: 409, + }; + case "challenge-invalid": + return { + abort: true, + reason: "payment_replay_binding_required", + message: "Cardano payment requires the opaque replay challenge from its original 402", + status: 403, + }; + case "capacity-exceeded": + return { + abort: true, + reason: "payment_replay_capacity_exceeded", + message: "the Cardano idempotency store is at capacity", + status: 503, + }; + case "in-progress": + return { + abort: true, + reason: "duplicate_settlement", + message: "the protected operation for this Cardano payment is already in progress", + status: 409, + }; + case "ambiguous": + return { + abort: true, + reason: "payment_replay_outcome_ambiguous", + message: "the protected operation may have completed and requires reconciliation", + status: 409, + }; + case "claimed": + this.replayOwners.set(replayOwner, { key: identity.key, ownerToken }); + return; + } + } + + /** + * Stores the completed handler response after settlement, including pending + * settlement. A later identical paid retry resumes settlement without + * running the protected operation again. + * + * @param context - Core after-settle hook context. + * @param context.transportContext - Transport carrying the buffered handler response. + * @param context.result - Settlement result, when this is a failure hook. + * @param context.result.errorReason - Stable settlement failure reason. + */ + private async storeProtectedResult(context: { + transportContext?: unknown; + result?: { errorReason?: string }; + }): Promise { + const transport = this.asReplayTransport(context.transportContext); + if (!transport) return; + const owner = this.replayOwner(transport); + if (!owner) return; + const claimOwner = this.replayOwners.get(owner); + if (!claimOwner) return; + if (context.result?.errorReason === ERR_SETTLEMENT_DEFINITIVELY_REJECTED) { + await this.operationStore.markAmbiguous(claimOwner.key, claimOwner.ownerToken); + this.replayOwners.delete(owner); + return; + } + if (transport.responseBody === undefined) { + await this.operationStore.markAmbiguous(claimOwner.key, claimOwner.ownerToken); + this.replayOwners.delete(owner); + return; + } + + const responseHeaders = transport.responseHeaders ?? {}; + const contentType = + Object.entries(responseHeaders).find( + ([name]) => name.toLowerCase() === "content-type", + )?.[1] ?? "application/octet-stream"; + const replayHeaders = Object.fromEntries( + Object.entries(responseHeaders).filter( + ([name]) => + name.toLowerCase() !== "content-type" && + !NON_REPLAYABLE_RESPONSE_HEADERS.has(name.toLowerCase()), + ), + ); + const response: CardanoStoredResponse = { + status: transport.responseStatus ?? 200, + contentType, + headers: replayHeaders, + body: Buffer.from(transport.responseBody), + isRaw: true, }; - return Promise.resolve({ ...paymentRequirements, extra }); + const responseBytes = + transport.responseBody.byteLength + Buffer.byteLength(JSON.stringify(replayHeaders), "utf8"); + const outcome = await this.operationStore.complete( + claimOwner.key, + claimOwner.ownerToken, + response, + responseBytes, + ); + this.replayOwners.delete(owner); + if (outcome === "not-owner") { + throw new Error("Cardano operation claim ownership was lost before completion"); + } + } + + /** + * Releases only a claim owned by the request whose handler failed. An abort + * from a concurrent duplicate must not release the original request's claim. + * + * @param context - Verified-payment cancellation context. + */ + private async cancelProtectedOperation( + context: Parameters>[0], + ): Promise { + const transport = this.asReplayTransport(context.transportContext); + if (!transport) return; + const owner = this.replayOwner(transport); + if (!owner) return; + const claimOwner = this.replayOwners.get(owner); + if (!claimOwner) return; + if (context.reason === "after_verify_aborted") { + await this.operationStore.release(claimOwner.key, claimOwner.ownerToken); + } else { + await this.operationStore.markAmbiguous(claimOwner.key, claimOwner.ownerToken); + } + this.replayOwners.delete(owner); + } + + /** + * Returns an object transport context suitable for WeakMap ownership. + * + * @param value - Untrusted transport context. + * @returns Object context, when present. + */ + private asReplayTransport(value: unknown): ReplayTransportContext | undefined { + return typeof value === "object" && value !== null + ? (value as ReplayTransportContext) + : undefined; + } + + /** + * Uses the stable request adapter as claim owner across verify and settle. + * + * @param transport - Transport context. + * @returns Stable ownership object. + */ + private replayOwner(transport: ReplayTransportContext): object | undefined { + const adapter = transport.request?.adapter; + return typeof adapter === "object" && adapter !== null ? adapter : undefined; + } + + /** + * Computes the logical replay key and canonical transaction id. + * + * @param paymentPayload - Verified Cardano payment. + * @param requirements - Canonical requirements. + * @returns Replay identity. + */ + private paymentIdentity( + paymentPayload: Parameters< + NonNullable + >[0]["paymentPayload"], + requirements: Parameters>[0]["requirements"], + ): { key: string; txHash: string } { + const transaction = (paymentPayload.payload as { transaction?: unknown }).transaction; + if (typeof transaction !== "string") throw new Error("Cardano transaction is missing"); + const decoded = decodeCardanoTransaction(transaction); + const extra = requirements.extra as Record | undefined; + const key = + extra?.assetTransferMethod === "masumi" + ? `masumi:${computeTermsDigest( + buildSignedTerms( + extra as unknown as CardanoExtraMasumi, + requirements as unknown as PaymentRequirements, + ), + )}` + : `transaction:${decoded.txHash}`; + return { key, txHash: decoded.txHash }; + } + + /** + * Builds a deterministic fingerprint for the protected HTTP operation. + * + * @param transport - Transport carrying request data. + * @returns Lowercase SHA-256 fingerprint. + */ + private async requestFingerprint( + transport: ReplayTransportContext, + ): Promise<{ fingerprint: string; isRequesterAuthenticated: boolean }> { + const adapter = transport.request?.adapter; + const method = transport.request?.method ?? adapter?.getMethod?.() ?? ""; + const url = adapter?.getUrl?.() ?? ""; + const contentType = adapter?.getHeader?.("content-type") ?? ""; + const rawBody = await adapter?.getRawBody?.(); + const body = rawBody === undefined ? await adapter?.getBody?.() : undefined; + const getHeader = (name: string): string | undefined => adapter?.getHeader?.(name); + const bindingContext: CardanoReplayBindingContext = { + method, + url, + contentType, + body, + ...(rawBody !== undefined ? { rawBody } : {}), + getHeader, + }; + const defaultBindingValues = DEFAULT_REQUEST_BINDING_HEADERS.map(name => ({ + name, + value: getHeader(name) ?? "", + })); + const requesterBinding = this.requestBinding + ? await this.requestBinding(bindingContext) + : defaultBindingValues.map(({ name, value }) => `${name}:${value}`).join("\n"); + // Header presence still contributes to the fingerprint, but only an explicit + // callback run after upstream authentication proves a requester identity. + const isRequesterAuthenticated = this.requestBinding + ? requesterBinding.trim().length > 0 + : false; + const canonicalBody = await this.canonicalRequestBody(bindingContext); + const bodyDigest = bytesToHex(sha256(canonicalBody)); + return { + fingerprint: bytesToHex( + sha256( + new TextEncoder().encode( + jcs({ + method: method.toUpperCase(), + url, + contentType, + bodyDigest, + requesterBinding, + }), + ), + ), + ), + isRequesterAuthenticated, + }; + } + + /** + * Returns the canonical fingerprint of one advertised requirement. + * + * @param requirements - Requirement to fingerprint. + * @returns Canonical SHA-256 fingerprint. + */ + private requirementsFingerprint(requirements: PaymentRequirements): string { + return bytesToHex(sha256(new TextEncoder().encode(jcs(requirements)))); + } + + /** + * Reads a challenge for one requirement from the reserved extension. + * + * @param extensions - Paid-payload extensions. + * @param requirementsFingerprint - Requirement fingerprint lookup key. + * @returns A valid challenge, if present. + */ + private replayChallenge( + extensions: Readonly> | undefined, + requirementsFingerprint: string, + ): string | undefined { + const challenges = this.replayExtension(extensions); + const challenge = challenges[requirementsFingerprint]; + return typeof challenge === "string" && REPLAY_CHALLENGE_HEX.test(challenge) + ? challenge + : undefined; + } + + /** + * Reads the closed challenge map from an extension value. + * + * @param extensions - Extension object to inspect. + * @returns Valid challenge entries only. + */ + private replayExtension( + extensions: Readonly> | undefined, + ): Record { + const value = extensions?.[CARDANO_REPLAY_EXTENSION_KEY]; + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const challenges = (value as { challenges?: unknown }).challenges; + if (typeof challenges !== "object" || challenges === null || Array.isArray(challenges)) { + return {}; + } + return Object.fromEntries( + Object.entries(challenges).filter( + (entry): entry is [string, string] => + typeof entry[1] === "string" && REPLAY_CHALLENGE_HEX.test(entry[1]), + ), + ); + } + + /** + * Returns unambiguous request-body bytes. Raw bytes win; otherwise a custom + * serializer or strict plain-JSON canonicalization is required. + * + * @param context - Request data available to the replay binder. + * @returns Exact bytes covered by the request fingerprint. + */ + private async canonicalRequestBody(context: CardanoReplayBindingContext): Promise { + let result: Uint8Array; + if (context.rawBody) { + result = Uint8Array.from(context.rawBody); + } else if (this.requestBodyBytes) { + const custom = await this.requestBodyBytes(context); + result = + typeof custom === "string" ? new TextEncoder().encode(custom) : Uint8Array.from(custom); + } else if (context.body === undefined) { + result = new Uint8Array(); + } else if (context.body instanceof Uint8Array) { + result = Uint8Array.from(context.body); + } else { + assertPlainJson(context.body, this.maxRequestBodyBytes); + result = new TextEncoder().encode(jcs(context.body)); + } + if (result.length > this.maxRequestBodyBytes) { + throw new Error("request body exceeds the replay fingerprint byte limit"); + } + return result; + } + + /** + * Ensures requirements use the same canonical wire forms enforced by the + * Cardano client and facilitator. + * + * @param value - Parsed amount and asset. + * @param source - Label included in validation errors. + * @returns The unchanged, validated value. + */ + private validateAssetAmount(value: AssetAmount, source: string): AssetAmount { + if (!POSITIVE_CANONICAL_AMOUNT_REGEX.test(value.amount)) { + throw new Error(`${source} amount must be a positive canonical integer: ${value.amount}`); + } + if (!CANONICAL_CARDANO_ASSET_REGEX.test(value.asset)) { + throw new Error(`${source} asset must use canonical lowercase Cardano form: ${value.asset}`); + } + return value; } /** diff --git a/typescript/packages/mechanisms/cardano/src/idempotency.ts b/typescript/packages/mechanisms/cardano/src/idempotency.ts new file mode 100644 index 0000000000..4c202a4441 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/idempotency.ts @@ -0,0 +1,492 @@ +import { randomBytes } from "node:crypto"; + +import type { CardanoSubmissionMode } from "./types"; + +/** Response retained after a protected Cardano operation completes. */ +export interface CardanoStoredResponse { + status: number; + contentType: string; + headers: Record; + body: unknown; + isRaw: boolean; +} + +/** Atomic claim for one paid protected operation. */ +export interface CardanoOperationClaim { + key: string; + txHash: string; + fingerprint: string; + requirementsFingerprint: string; + replayChallenge?: string; + requireReplayChallenge: boolean; + ownerToken: string; +} + +/** Result of attempting to claim a protected operation. */ +export type CardanoOperationClaimResult = + | { status: "claimed" } + | { status: "transaction-conflict" } + | { status: "request-conflict" } + | { status: "in-progress" } + | { status: "ambiguous" } + | { status: "completed"; response: CardanoStoredResponse } + | { status: "completed-without-response" } + | { status: "challenge-invalid" } + | { status: "capacity-exceeded" }; + +/** Binding stored for an opaque challenge issued in a 402 response. */ +export interface CardanoReplayChallengeBinding { + fingerprint: string; + requirementsFingerprint: string; + expiresAt: number; +} + +/** Result of issuing a replay challenge. */ +export type CardanoReplayChallengeResult = + | { status: "issued"; challenge: string } + | { status: "capacity-exceeded" }; + +/** + * Persistence boundary for protected-operation idempotency. + * + * Production implementations MUST make `claim()` an atomic compare-and-set + * operation in durable storage shared by every worker and deployment. + */ +export interface CardanoOperationStore { + issueChallenge(binding: CardanoReplayChallengeBinding): Promise; + validateChallenge( + challenge: string, + binding: Omit, + ): Promise; + claim(claim: CardanoOperationClaim): Promise; + complete( + key: string, + ownerToken: string, + response: CardanoStoredResponse, + responseBytes: number, + ): Promise<"stored" | "response-too-large" | "not-owner">; + markAmbiguous(key: string, ownerToken: string): Promise<"stored" | "not-owner">; + release(key: string, ownerToken: string): Promise; +} + +/** Capacity controls for the process-local protected-operation store. */ +export interface InMemoryCardanoOperationStoreOptions { + maxEntries?: number; + maxChallenges?: number; + maxResponseBytes?: number; + maxTotalResponseBytes?: number; +} + +interface OperationRecord extends CardanoOperationClaim { + state: "in-progress" | "ambiguous" | "completed"; + response?: CardanoStoredResponse; + responseBytes: number; +} + +interface ReplayChallengeRecord extends CardanoReplayChallengeBinding { + claimedKey?: string; +} + +/** Bounded process-local store for tests and explicitly single-process servers. */ +export class InMemoryCardanoOperationStore implements CardanoOperationStore { + private readonly records = new Map(); + private readonly challenges = new Map(); + private readonly maxEntries: number; + private readonly maxChallenges: number; + private readonly maxResponseBytes: number; + private readonly maxTotalResponseBytes: number; + private totalResponseBytes = 0; + + /** + * Creates a bounded process-local operation store. + * + * @param options - Entry and response-byte limits. + */ + constructor(options: InMemoryCardanoOperationStoreOptions = {}) { + this.maxEntries = positiveInteger(options.maxEntries ?? 4096, "maxEntries"); + this.maxChallenges = positiveInteger(options.maxChallenges ?? 4096, "maxChallenges"); + this.maxResponseBytes = nonNegativeInteger( + options.maxResponseBytes ?? 2 * 1024 * 1024, + "maxResponseBytes", + ); + this.maxTotalResponseBytes = nonNegativeInteger( + options.maxTotalResponseBytes ?? 64 * 1024 * 1024, + "maxTotalResponseBytes", + ); + } + + /** + * Issues a fresh opaque challenge bound to one protected request and quote. + * + * @param binding - Request, requirements and expiry binding. + * @returns The challenge, or a capacity failure. + */ + async issueChallenge( + binding: CardanoReplayChallengeBinding, + ): Promise { + if (!Number.isSafeInteger(binding.expiresAt) || binding.expiresAt <= Date.now()) { + throw new Error("replay challenge expiry must be a future safe integer"); + } + this.pruneExpiredChallenges(); + if (this.challenges.size >= this.maxChallenges) return { status: "capacity-exceeded" }; + let challenge: string; + do { + challenge = randomBytes(32).toString("hex"); + } while (this.challenges.has(challenge)); + this.challenges.set(challenge, { ...binding }); + return { status: "issued", challenge }; + } + + /** + * Checks that an echoed challenge belongs to this request and requirement. + * A challenge retained by an operation remains valid for idempotent retries + * after its issuance lifetime; an expired unused challenge does not. + * + * @param challenge - Opaque challenge echoed by the client. + * @param binding - Expected request and requirement fingerprints. + * @returns Whether the challenge is valid for this response. + */ + async validateChallenge( + challenge: string, + binding: Omit, + ): Promise { + const record = this.challenges.get(challenge); + if ( + record && + record.fingerprint === binding.fingerprint && + record.requirementsFingerprint === binding.requirementsFingerprint + ) { + if (record.expiresAt > Date.now()) return true; + if (record.claimedKey && this.records.get(record.claimedKey)?.replayChallenge === challenge) { + return true; + } + this.challenges.delete(challenge); + return false; + } + for (const operation of this.records.values()) { + if ( + operation.replayChallenge === challenge && + operation.fingerprint === binding.fingerprint && + operation.requirementsFingerprint === binding.requirementsFingerprint + ) { + return true; + } + } + return false; + } + + /** + * Atomically claims an operation or returns its existing state. + * + * @param claim - Payment, request and owner binding. + * @returns The claim outcome. + */ + async claim(claim: CardanoOperationClaim): Promise { + const existing = this.records.get(claim.key); + if (existing) { + if ( + existing.replayChallenge !== undefined + ? existing.replayChallenge !== claim.replayChallenge + : claim.requireReplayChallenge + ) { + return { status: "challenge-invalid" }; + } + if (existing.txHash !== claim.txHash) return { status: "transaction-conflict" }; + if (existing.fingerprint !== claim.fingerprint) return { status: "request-conflict" }; + if (existing.state === "in-progress") return { status: "in-progress" }; + if (existing.state === "ambiguous") return { status: "ambiguous" }; + if (!existing.response) return { status: "completed-without-response" }; + return { status: "completed", response: cloneResponse(existing.response) }; + } + let replayChallenge: string | undefined; + if (claim.replayChallenge !== undefined || claim.requireReplayChallenge) { + const challenge = + claim.replayChallenge === undefined + ? undefined + : this.challenges.get(claim.replayChallenge); + if ( + !challenge || + challenge.expiresAt <= Date.now() || + challenge.fingerprint !== claim.fingerprint || + challenge.requirementsFingerprint !== claim.requirementsFingerprint || + (challenge.claimedKey !== undefined && challenge.claimedKey !== claim.key) + ) { + return { status: "challenge-invalid" }; + } + replayChallenge = claim.replayChallenge; + } + if (this.records.size >= this.maxEntries) return { status: "capacity-exceeded" }; + this.records.set(claim.key, { + ...claim, + ...(replayChallenge ? { replayChallenge } : {}), + state: "in-progress", + responseBytes: 0, + }); + if (replayChallenge) { + const challenge = this.challenges.get(replayChallenge); + if (challenge) challenge.claimedKey = claim.key; + } + return { status: "claimed" }; + } + + /** + * Completes an owned claim and retains its bounded response. + * + * @param key - Logical payment key. + * @param ownerToken - Unpredictable token returned by the claimant. + * @param response - Handler response to retain. + * @param responseBytes - Buffered response size used for capacity checks. + * @returns Storage outcome. + */ + async complete( + key: string, + ownerToken: string, + response: CardanoStoredResponse, + responseBytes: number, + ): Promise<"stored" | "response-too-large" | "not-owner"> { + const record = this.records.get(key); + if (!record || record.ownerToken !== ownerToken || record.state !== "in-progress") { + return "not-owner"; + } + record.state = "completed"; + if ( + responseBytes > this.maxResponseBytes || + this.totalResponseBytes - record.responseBytes + responseBytes > this.maxTotalResponseBytes + ) { + this.totalResponseBytes -= record.responseBytes; + record.response = undefined; + record.responseBytes = 0; + return "response-too-large"; + } + this.totalResponseBytes -= record.responseBytes; + record.response = cloneResponse(response); + record.responseBytes = responseBytes; + this.totalResponseBytes += responseBytes; + return "stored"; + } + + /** + * Permanently retains an owned claim when the handler may have produced side + * effects but no response can be replayed safely. + * + * @param key - Logical payment key. + * @param ownerToken - Claimant's owner token. + * @returns Whether the owned claim was retained. + */ + async markAmbiguous(key: string, ownerToken: string): Promise<"stored" | "not-owner"> { + const record = this.records.get(key); + if (!record || record.ownerToken !== ownerToken || record.state !== "in-progress") { + return "not-owner"; + } + record.state = "ambiguous"; + record.response = undefined; + this.totalResponseBytes -= record.responseBytes; + record.responseBytes = 0; + return "stored"; + } + + /** + * Releases a claim only when the owner token matches. + * + * @param key - Logical payment key. + * @param ownerToken - Claimant's owner token. + */ + async release(key: string, ownerToken: string): Promise { + const record = this.records.get(key); + if (record?.ownerToken === ownerToken && record.state === "in-progress") { + this.delete(key, record); + } + } + + /** + * Deletes a record and updates byte accounting. + * + * @param key - Record key. + * @param record - Record being deleted. + */ + private delete(key: string, record: OperationRecord): void { + this.totalResponseBytes -= record.responseBytes; + this.records.delete(key); + } + + /** Removes expired, unused quote challenges before capacity checks. */ + private pruneExpiredChallenges(): void { + const now = Date.now(); + for (const [challenge, binding] of this.challenges) { + if (binding.expiresAt <= now) this.challenges.delete(challenge); + } + } +} + +/** Atomic claim for a canonical Cardano transaction. */ +export interface CardanoSubmissionClaim { + txHash: string; + mode: CardanoSubmissionMode; + ownerToken: string; +} + +/** One atomic facilitator claim for transaction and optional Masumi terms. */ +export interface CardanoSettlementClaim extends CardanoSubmissionClaim { + termsDigest?: string; +} + +/** Result of claiming a facilitator settlement. */ +export type CardanoSettlementClaimResult = + | "fresh" + | "in-flight" + | "submitted" + | "rejected" + | "mode-conflict" + | "terms-conflict" + | "capacity-exceeded"; + +/** Persistence boundary for facilitator transaction and Masumi-terms claims. */ +export interface CardanoSettlementStore { + claimSettlement(claim: CardanoSettlementClaim): Promise; + markSubmitted(txHash: string, ownerToken: string): Promise; + markRejected(txHash: string, ownerToken: string): Promise; +} + +interface SubmissionRecord extends CardanoSettlementClaim { + inFlight: boolean; + submitted: boolean; + rejected: boolean; +} + +/** Bounded process-local facilitator store for tests and disposable development. */ +export class InMemoryCardanoSettlementStore implements CardanoSettlementStore { + private readonly submissions = new Map(); + private readonly terms = new Map(); + private readonly maxEntries: number; + + /** + * Creates a bounded process-local settlement store. + * + * @param maxEntries - Combined submission and terms entry limit. + */ + constructor(maxEntries = 4096) { + this.maxEntries = positiveInteger(maxEntries, "maxEntries"); + } + + /** + * Atomically claims one canonical transaction, submission mode and optional + * Masumi terms digest. No partial terms binding is left on failure. + * + * @param claim - Transaction, mode and owner binding. + * @returns The claim outcome. + */ + async claimSettlement(claim: CardanoSettlementClaim): Promise { + const existingTerms = claim.termsDigest ? this.terms.get(claim.termsDigest) : undefined; + if (existingTerms && existingTerms.txHash !== claim.txHash) return "terms-conflict"; + + const existing = this.submissions.get(claim.txHash); + if (existing) { + if (existing.mode !== claim.mode) return "mode-conflict"; + if (existing.termsDigest !== claim.termsDigest) return "terms-conflict"; + } + + const requiredEntries = (existing ? 0 : 1) + (claim.termsDigest && !existingTerms ? 1 : 0); + if (this.entryCount() + requiredEntries > this.maxEntries) return "capacity-exceeded"; + + if (claim.termsDigest && !existingTerms) { + this.terms.set(claim.termsDigest, { txHash: claim.txHash }); + } + if (!existing) { + this.submissions.set(claim.txHash, { + txHash: claim.txHash, + mode: claim.mode, + ...(claim.termsDigest ? { termsDigest: claim.termsDigest } : {}), + ownerToken: claim.ownerToken, + inFlight: true, + submitted: false, + rejected: false, + }); + return "fresh"; + } + if (existing.rejected) return "rejected"; + return existing.inFlight ? "in-flight" : "submitted"; + } + + /** + * Marks an owned transaction claim as submitted. + * + * @param txHash - Canonical transaction ID. + * @param ownerToken - Claimant's owner token. + */ + async markSubmitted(txHash: string, ownerToken: string): Promise { + const record = this.submissions.get(txHash); + if (record?.ownerToken === ownerToken) { + record.inFlight = false; + record.submitted = true; + record.rejected = false; + } + } + + /** + * Permanently records a definitive pre-ledger rejection. Retaining this + * tombstone prevents a paid retry from resubmitting the same invalid bytes. + * + * @param txHash - Canonical transaction ID. + * @param ownerToken - Claimant's owner token. + */ + async markRejected(txHash: string, ownerToken: string): Promise { + const record = this.submissions.get(txHash); + if (record?.ownerToken === ownerToken) { + record.inFlight = false; + record.submitted = false; + record.rejected = true; + } + } + + /** + * Counts all retained records against the shared entry limit. + * + * @returns Combined number of retained terms and submission records. + */ + private entryCount(): number { + return this.submissions.size + this.terms.size; + } +} + +/** + * Copies a retained response so callers cannot mutate store state. + * + * @param response - Stored response. + * @returns Independent response copy. + */ +function cloneResponse(response: CardanoStoredResponse): CardanoStoredResponse { + return { + ...response, + headers: { ...response.headers }, + body: Buffer.isBuffer(response.body) + ? Buffer.from(response.body) + : structuredClone(response.body), + }; +} + +/** + * Validates a positive safe integer setting. + * + * @param value - Candidate value. + * @param name - Setting name used in errors. + * @returns Validated value. + */ +function positiveInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) + throw new Error(`${name} must be a positive integer`); + return value; +} + +/** + * Validates a non-negative safe integer setting. + * + * @param value - Candidate value. + * @param name - Setting name used in errors. + * @returns Validated value. + */ +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer`); + } + return value; +} diff --git a/typescript/packages/mechanisms/cardano/src/index.ts b/typescript/packages/mechanisms/cardano/src/index.ts index 82212b491d..0eb9a592e3 100644 --- a/typescript/packages/mechanisms/cardano/src/index.ts +++ b/typescript/packages/mechanisms/cardano/src/index.ts @@ -2,10 +2,22 @@ export * from "./exact"; // Masumi escrow (vested_pay) support +export * from "./exact/masumi/blueprint"; export * from "./exact/masumi/constants"; export * from "./exact/masumi/datum"; -export { buildMasumiLockInline } from "./exact/masumi/lock"; -export { verifyMasumiLock } from "./exact/masumi/verify"; +export * from "./exact/masumi/digests"; +export * from "./exact/masumi/identifier"; +export * from "./exact/masumi/issue"; +export { verifySellerTermsSignature } from "./exact/masumi/cose"; +export { jcs, jcsBytes } from "./exact/masumi/jcs"; +export { buildMasumiLock, type MasumiBuyerInput, type MasumiLock } from "./exact/masumi/lock"; +export { validateMasumiExtra, type MasumiSchemaResult } from "./exact/masumi/schema"; +export { + verifyMasumiAuthorization, + verifyMasumiLock, + type MasumiDeploymentValidator, + type MasumiRegistryValidator, +} from "./exact/masumi/verify"; // Script method (generic contract locking with arbitrary datums) export { buildScriptDatumInline } from "./exact/script/datum"; @@ -13,9 +25,15 @@ export { buildScriptDatumInline } from "./exact/script/datum"; // Types export * from "./types"; +// Replay and settlement idempotency stores +export * from "./idempotency"; + // Constants export * from "./constants"; +// Submission / confirmation policy helpers +export * from "./policy"; + // Signer protocols export * from "./signer"; diff --git a/typescript/packages/mechanisms/cardano/src/limits.ts b/typescript/packages/mechanisms/cardano/src/limits.ts new file mode 100644 index 0000000000..971a246a9a --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/limits.ts @@ -0,0 +1,37 @@ +/** Defensive implementation budgets. They do not change Cardano ledger rules. */ + +/** Largest signed transaction this package decodes before consulting a node. */ +export const MAX_CARDANO_TRANSACTION_BYTES = 64 * 1024; +/** Maximum transaction inputs resolved through provider calls during verification. */ +export const MAX_CARDANO_TRANSACTION_INPUTS = 256; +/** Maximum simultaneous provider lookups for transaction inputs. */ +export const MAX_CARDANO_INPUT_LOOKUP_CONCURRENCY = 8; +/** Largest arbitrary inline Plutus script accepted from payment requirements. */ +export const MAX_CARDANO_SCRIPT_BYTES = 64 * 1024; +/** Largest arbitrary inline datum accepted from payment requirements. */ +export const MAX_CARDANO_DATUM_BYTES = 64 * 1024; +/** Maximum number of parameters applied to an arbitrary Plutus script. */ +export const MAX_CARDANO_SCRIPT_PARAMETERS = 64; +/** Aggregate UTF-8/hex input budget for arbitrary script parameters. */ +export const MAX_CARDANO_SCRIPT_PARAMETER_BYTES = 64 * 1024; + +/** Maximum number of request-commitment parts in one Masumi requirements block. */ +export const MAX_MASUMI_COMMITMENT_PARTS = 32; +/** Maximum content represented by one Masumi commitment part. */ +export const MAX_MASUMI_COMMITMENT_CONTENT_BYTES = 1024 * 1024; +/** Maximum number of weighted admin keys in a custom deployment. */ +export const MAX_MASUMI_ADMIN_KEYS = 64; +/** Maximum compressed bytes in the Masumi compatibility identifier. */ +export const MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES = 8 * 1024; +/** Maximum decoded text characters in the Masumi compatibility identifier. */ +export const MAX_MASUMI_IDENTIFIER_TEXT_CHARS = 32 * 1024; +/** Maximum bytes in one COSE key or signature field. */ +export const MAX_MASUMI_COSE_BYTES = 16 * 1024; +/** Maximum entries in the pure deployment script-hash memoization cache. */ +export const MAX_MASUMI_SCRIPT_HASH_CACHE_ENTRIES = 256; + +/** Default raw/canonical body budget for protected-operation fingerprints. */ +export const DEFAULT_CARDANO_REPLAY_BODY_BYTES = 2 * 1024 * 1024; + +/** Default deadline for reference-signer provider operations. */ +export const DEFAULT_CARDANO_PROVIDER_TIMEOUT_MS = 10_000; diff --git a/typescript/packages/mechanisms/cardano/src/policy.ts b/typescript/packages/mechanisms/cardano/src/policy.ts new file mode 100644 index 0000000000..36088adf37 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/src/policy.ts @@ -0,0 +1,128 @@ +import { + DEFAULT_L1_CONFIRMATIONS, + MAX_L1_CONFIRMATIONS, + MIN_L1_CONFIRMATIONS, + SUBMISSION_POLICY_CLIENT, + SUBMISSION_POLICY_EITHER, + SUBMISSION_POLICY_SERVER, +} from "./constants"; +import type { + CardanoConfirmationPolicy, + CardanoSubmissionMode, + CardanoSubmissionPolicy, +} from "./types"; + +/** + * The shared submission and confirmation policies, resolved from a + * requirements `extra` block. Every assetTransferMethod carries these at the + * top level of `extra`; they are bound by exact `accepted` matching and are + * never part of the Masumi `termsDigest`. + */ +export interface ResolvedCardanoPolicies { + submissionPolicy: CardanoSubmissionPolicy; + confirmationPolicy: CardanoConfirmationPolicy; +} + +/** + * Reads the submission and confirmation policy out of a requirements `extra` + * block, applying the spec defaults (`server`, one confirmation). + * + * Total: an unparseable policy yields `null` rather than throwing, so callers + * can turn it into a rejection reason. + * + * @param extra - The requirements' `extra` block, if any. + * @returns The resolved policies, or `null` when either field is malformed. + */ +export function resolveCardanoPolicies( + extra: Record | undefined, +): ResolvedCardanoPolicies | null { + const submissionPolicy = normalizeSubmissionPolicy(extra?.submissionPolicy); + if (submissionPolicy === null) return null; + const confirmationPolicy = normalizeConfirmationPolicy(extra?.confirmationPolicy); + if (confirmationPolicy === null) return null; + return { submissionPolicy, confirmationPolicy }; +} + +/** + * Normalizes `extra.submissionPolicy`. An absent value is `server`. + * + * @param value - The raw declared value. + * @returns The policy, or `null` when the value is not one of the three literals. + */ +export function normalizeSubmissionPolicy(value: unknown): CardanoSubmissionPolicy | null { + if (value === undefined) return SUBMISSION_POLICY_SERVER; + if ( + value === SUBMISSION_POLICY_SERVER || + value === SUBMISSION_POLICY_CLIENT || + value === SUBMISSION_POLICY_EITHER + ) { + return value; + } + return null; +} + +/** + * Normalizes `extra.confirmationPolicy`, a closed object whose only member is + * an integer `l1Confirmations` from `-1` through `20`. An absent policy is + * `{ l1Confirmations: 1 }`. + * + * @param value - The raw declared value. + * @returns The policy, or `null` when it is malformed or out of range. + */ +export function normalizeConfirmationPolicy(value: unknown): CardanoConfirmationPolicy | null { + if (value === undefined) return { l1Confirmations: DEFAULT_L1_CONFIRMATIONS }; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const keys = Object.keys(value); + if (keys.length !== 1 || keys[0] !== "l1Confirmations") return null; + const l1Confirmations = (value as CardanoConfirmationPolicy).l1Confirmations; + if ( + typeof l1Confirmations !== "number" || + !Number.isInteger(l1Confirmations) || + l1Confirmations < MIN_L1_CONFIRMATIONS || + l1Confirmations > MAX_L1_CONFIRMATIONS + ) { + return null; + } + return { l1Confirmations }; +} + +/** + * Normalizes `payload.submissionMode`. An absent value is `server`; `either` is + * a policy and is never a valid payload mode. + * + * @param value - The raw payload value. + * @returns The mode, or `null` when the value is neither literal. + */ +export function normalizeSubmissionMode(value: unknown): CardanoSubmissionMode | null { + if (value === undefined) return SUBMISSION_POLICY_SERVER; + if (value === SUBMISSION_POLICY_SERVER || value === SUBMISSION_POLICY_CLIENT) return value; + return null; +} + +/** + * Whether a policy admits a normalized payload submission mode. + * + * @param policy - The declared requirements policy. + * @param mode - The normalized payload mode. + * @returns True when the mode is selectable under the policy. + */ +export function submissionModeAllowed( + policy: CardanoSubmissionPolicy, + mode: CardanoSubmissionMode, +): boolean { + return policy === SUBMISSION_POLICY_EITHER || policy === mode; +} + +/** + * Whether observed L1 evidence meets a required threshold. `confirmations` is + * the strongest verified evidence: `-1` for authenticated mempool acceptance, + * `0` for canonical block inclusion, and `n` for `n` newer canonical blocks. + * Greater evidence always satisfies a lower threshold. + * + * @param observed - The strongest verified evidence level. + * @param required - The threshold from `confirmationPolicy.l1Confirmations`. + * @returns True when the evidence suffices. + */ +export function confirmationsSatisfy(observed: number, required: number): boolean { + return observed >= required; +} diff --git a/typescript/packages/mechanisms/cardano/src/signer.ts b/typescript/packages/mechanisms/cardano/src/signer.ts index 36adca0f50..732f08437d 100644 --- a/typescript/packages/mechanisms/cardano/src/signer.ts +++ b/typescript/packages/mechanisms/cardano/src/signer.ts @@ -3,7 +3,7 @@ import { Assets, type Chain, Client, - Data, + Credential, mainnet, preprod, preview, @@ -12,6 +12,7 @@ import { TransactionInput, } from "@evolution-sdk/evolution"; import { addressFromSeed } from "@evolution-sdk/evolution/sdk/wallet/Derivation"; +import type { PaymentRequirements, ResourceInfo } from "@x402/core/types"; import { ASSET_TRANSFER_METHOD_MASUMI, @@ -22,11 +23,29 @@ import { LOVELACE_ASSET, normalizeCardanoNetwork, } from "./constants"; -import { masumiMinUtxoLovelace } from "./exact/masumi/constants"; -import { buildMasumiLockInline } from "./exact/masumi/lock"; +import { + MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE, + MASUMI_MAX_DEADLINE_HORIZON_MS, +} from "./exact/masumi/constants"; +import { buildMasumiLock, type MasumiBuyerInput } from "./exact/masumi/lock"; +import { parseMasumiLockDatum } from "./exact/masumi/datum"; +import { + verifyMasumiDatumInvariants, + verifyMasumiAuthorization, + type MasumiDeploymentValidator, + type MasumiRegistryValidator, +} from "./exact/masumi/verify"; +import { isKeyCredentialAddressOn, validateMasumiExtra } from "./exact/masumi/schema"; import { buildScriptDatumInline } from "./exact/script/datum"; -import type { CardanoExtra, CardanoExtraMasumi, CardanoExtraScript } from "./types"; -import { parseAssetUnit, parseUtxoRef } from "./utils"; +import { DEFAULT_CARDANO_PROVIDER_TIMEOUT_MS } from "./limits"; +import type { + CardanoExtra, + CardanoExtraMasumi, + CardanoExtraScript, + CardanoSettlementLayer, + CardanoSubmissionMode, +} from "./types"; +import { decodeCardanoTransactionBytes, parseAssetUnit, parseUtxoRef } from "./utils"; /** * Provider connection used by the reference signers. Exactly one of @@ -34,8 +53,60 @@ import { parseAssetUnit, parseUtxoRef } from "./utils"; * Evolution SDK provider configs. */ export type CardanoProviderConfig = - | { blockfrost: { baseUrl: string; projectId?: string }; koios?: never } - | { koios: { baseUrl: string; token?: string }; blockfrost?: never }; + | { + blockfrost: { baseUrl: string; projectId?: string }; + koios?: never; + requestTimeoutMs?: number; + } + | { + koios: { baseUrl: string; token?: string }; + blockfrost?: never; + requestTimeoutMs?: number; + }; + +/** + * Resolves and validates the deadline shared by every reference-signer + * provider operation. + * + * @param provider - Provider configuration. + * @returns Validated timeout in milliseconds. + */ +function providerTimeoutMs(provider: CardanoProviderConfig): number { + const timeoutMs = provider.requestTimeoutMs ?? DEFAULT_CARDANO_PROVIDER_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 120_000) { + throw new Error("Cardano provider requestTimeoutMs must be an integer from 1 to 120000"); + } + return timeoutMs; +} + +/** + * Bounds a provider promise even when its SDK transport exposes no abort + * signal. The underlying request may finish later, but callers never wait past + * the configured deadline. + * + * @param operation - Provider promise to await. + * @param timeoutMs - Deadline in milliseconds. + * @param name - Operation name used in timeout errors. + * @returns The provider result. + */ +export async function withCardanoProviderTimeout( + operation: PromiseLike, + timeoutMs: number, + name: string, +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(new Error(`Cardano provider ${name} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + }); + try { + return await Promise.race([Promise.resolve(operation), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} /** * Resolves an x402 Cardano network identifier to an Evolution SDK chain preset. @@ -162,6 +233,14 @@ export interface ClientCardanoSignInput { * assetTransferMethod and any method-specific metadata). */ extra?: Record; + /** + * The submission mode resolved from `extra.submissionPolicy`. In `client` + * mode the signer MUST broadcast the transaction before returning, because + * the facilitator will authenticate it instead of submitting it. + */ + submissionMode: CardanoSubmissionMode; + /** Protected resource, used to validate registered Masumi agent endpoints. */ + resource?: ResourceInfo; } /** @@ -176,6 +255,19 @@ export interface ClientCardanoSignResult { * UTXO reference (`txHashHex#index`) used as nonce. MUST appear as a tx input. */ nonce: string; + /** + * The mode the signer actually honoured. In `client` mode the signer MUST + * have broadcast the transaction before returning. + */ + submissionMode?: CardanoSubmissionMode; + /** + * Masumi only: the ledger the payment settles on. + */ + settlementLayer?: CardanoSettlementLayer; + /** + * Masumi + Hydra only: the canonical protocol head id. + */ + headId?: string; } /** @@ -183,6 +275,19 @@ export interface ClientCardanoSignResult { */ export type CardanoSettlementStatus = "confirmed" | "mempool"; +/** + * Authenticated settlement evidence for one transaction. + * + * `confirmations` reports the strongest verified evidence: `-1` for + * authenticated mempool acceptance, `0` for inclusion in a canonical block, and + * `n` for `n` newer canonical blocks. It is meaningless when `status` is + * `unknown`, which means the ledger has no record of the transaction. + */ +export interface CardanoSettlementEvidence { + status: "unknown" | "mempool" | "confirmed"; + confirmations: number; +} + /** * Result of submitting a transaction via a facilitator signer. */ @@ -207,9 +312,21 @@ export interface CardanoUtxoSnapshot { */ exists: boolean; /** - * Optional bech32 address that controls the UTXO. Useful for diagnostics. + * The bech32 address that controls the UTXO. Implementations SHOULD report it + * even when `exists` is false: in client-submission mode the payment + * transaction has already consumed the nonce, and this address is how the + * facilitator resolves the payer (and, for Masumi, the datum's `buyer`). */ address?: string; + /** Lovelace held by the UTXO, required for pre-submit phase-1 validation. */ + coin?: bigint; + /** Native assets held by the UTXO, keyed by canonical asset unit. */ + assets?: Record; + /** + * Lowercase payment-key hash controlling this UTXO. Required before server + * submission; omitted for script or legacy addresses. + */ + paymentKeyHash?: string; } /** @@ -244,6 +361,20 @@ export interface FacilitatorCardanoSigner { */ getUtxo(ref: string, network: string): Promise; + /** + * Optional full ledger phase-1 validator for server-submitted transactions. + * Use this to support script-controlled funding inputs or balance-changing + * operations outside the reference payment-only shape. It MUST throw unless + * the exact signed transaction passes all phase-1 ledger rules against the + * authenticated current UTXO set and protocol parameters. Transaction + * evaluation alone is insufficient because it permits unbalanced and + * unauthenticated transactions. + * + * @param signedTransactionBase64 - Exact signed transaction CBOR. + * @param network - The x402 network identifier. + */ + validatePhase1Transaction?(signedTransactionBase64: string, network: string): Promise; + /** * Returns the current absolute slot number for the supplied network. * @@ -267,6 +398,13 @@ export interface FacilitatorCardanoSigner { network: string, ): Promise; + /** + * Optional classifier for a submission error that definitively proves the + * transaction was rejected before it could be accepted. Ambiguous transport, + * timeout and provider errors MUST return false or leave this hook absent. + */ + isDefinitiveSubmissionRejection?(error: unknown): boolean; + /** * Optional: waits for confirmation of a previously submitted transaction. * Implementations that already wait inside `submitTransaction` may return @@ -296,6 +434,28 @@ export interface FacilitatorCardanoSigner { */ evaluateTransaction?(signedTransactionBase64: string, network: string): Promise; + /** + * Optional: reads authenticated settlement evidence for one transaction. + * + * Required for client-submission mode (the facilitator must authenticate the + * transaction the client already broadcast instead of submitting it) and for + * any `confirmationPolicy.l1Confirmations` above `0`, which needs the real + * canonical depth. Without this hook, the facilitator cannot advertise + * `client` submission or confirmation depths above canonical inclusion. + * + * Implementations MUST return `status: "unknown"` when the ledger has no + * record of the transaction, and SHOULD throw only on lookup failure. A + * transaction the ledger marked phase-2 invalid (`valid_contract: false`) + * MUST also report `unknown`: it lands under its own id but consumes + * collateral instead of its inputs and creates none of its declared outputs, + * so it is not evidence that anything was paid. + * + * @param txHash - The canonical Cardano transaction id (hex). + * @param network - The x402 network identifier. + * @returns The strongest verified evidence for that transaction. + */ + getTransactionEvidence?(txHash: string, network: string): Promise; + /** * Optional: reads the live `coinsPerUtxoByte` protocol parameter. When * implemented, the facilitator's `verify()` uses it to reject payments whose @@ -329,6 +489,51 @@ export interface ClientCardanoSignerConfig { * Optional account index for key derivation. Defaults to 0. */ accountIndex?: number; + /** + * Supplies the buyer-side datum fields for a Masumi lock, called once per + * payment with the server's masumi `extra`. Only `buyer_return_address` is + * buyer-chosen — every other datum field comes from the seller-signed + * `terms`. Omit it to take the contract default (`None`). + */ + masumiBuyerInput?: (extra: CardanoExtraMasumi) => MasumiBuyerInput | Promise; + /** + * Independently validates a Masumi registry claim before paying. Without one, + * a 402 whose `terms.agentIdentifier` claims a registered agent is refused + * rather than paid on an unverified reputation claim. + */ + validateMasumiRegistryClaim?: MasumiRegistryValidator; + /** + * The buyer's own content for `inputCommitment` parts the issuer chose not to + * echo, keyed by part name. The issuer may omit content for parts derived + * from the buyer's own request bytes, and the buyer recomputes those digests + * from what it actually sent. A 402 carrying an omitted part that is not + * supplied here is **refused**: a seller free to invent that part's digest is + * free to bind the escrow to a request that was never made. + */ + masumiRequestContent?: Record; + /** + * Explicitly approves one non-canonical `extra.deployment`. Choosing a + * deployment is choosing the escrow's dispute arbitrators, so approval must + * inspect the exact network, address and applied parameters. + */ + validateCustomMasumiDeployment?: MasumiDeploymentValidator; + /** + * Ceiling on the `collateral_return_lovelace` this client will lock, + * defaulting to {@link MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE}. + * + * The collateral is derived from the datum size, and the datum carries the + * seller's `reference_key` and `reference_signature` verbatim — so a seller + * that pads them inflates the buyer's own locked funds. The collateral does + * come back, but not before `submit_result_time`. + */ + masumiMaxCollateralLovelace?: bigint; + /** + * How far past now `external_dispute_unlock_time` may sit, defaulting to + * {@link MASUMI_MAX_DEADLINE_HORIZON_MS}. Until `submit_result_time` passes + * the buyer can recover neither the payment nor its collateral, so this bounds + * how long a 402 can hold the wallet's funds. + */ + masumiMaxDeadlineHorizonMs?: bigint; } /** @@ -346,6 +551,7 @@ export interface ClientCardanoSignerConfig { */ export function toClientCardanoSigner(config: ClientCardanoSignerConfig): ClientCardanoSigner { const chain = resolveChain(config.network); + const timeoutMs = providerTimeoutMs(config.provider); const mnemonic = normalizeMnemonic(config.mnemonic); const client = withProvider(Client.make(chain), config.provider).withSeed({ mnemonic, @@ -374,8 +580,82 @@ export function toClientCardanoSigner(config: ClientCardanoSignerConfig): Client ); } + // Validate the 402 before touching the wallet: a malicious or malformed + // Masumi 402 must be refused before any funds are selected, and in client + // mode before anything is broadcast. + const extra = input.extra as CardanoExtra | undefined; + let masumiExtra: CardanoExtraMasumi | undefined; + let masumiBuyerInput: MasumiBuyerInput = {}; + let settlementLayer: CardanoSettlementLayer | undefined; + if (extra?.assetTransferMethod === ASSET_TRANSFER_METHOD_MASUMI) { + const schema = validateMasumiExtra(extra, input.network); + if (!schema.ok) { + throw new Error(`Masumi payment requirements are invalid: ${schema.detail}`); + } + masumiExtra = schema.extra; + // The client MUST verify the seller authorization itself — it is about + // to move real value, and in client-submission mode it broadcasts before + // any facilitator sees the payment. Skipping this would let a malicious + // 402 send funds to a non-escrow address or bind them to terms no seller + // ever signed. + const authorization = await verifyMasumiAuthorization( + masumiExtra, + { + scheme: "exact", + network: input.network as PaymentRequirements["network"], + asset: input.asset, + amount: input.amount, + payTo: input.payTo, + maxTimeoutSeconds: input.maxTimeoutSeconds, + extra: extra as unknown as Record, + }, + { + // The client sees the original request, so every commitment part + // must verify — an unverifiable one binds the escrow to a request + // the buyer never made. + requireAllPartContent: true, + ...(config.masumiRequestContent + ? { localCommitmentContent: config.masumiRequestContent } + : {}), + ...(config.validateMasumiRegistryClaim + ? { validateRegistryClaim: config.validateMasumiRegistryClaim } + : {}), + ...(input.resource ? { resource: input.resource } : {}), + ...(config.validateCustomMasumiDeployment + ? { validateCustomDeployment: config.validateCustomMasumiDeployment } + : {}), + // Always set: the horizon is buyer policy, so the client is the + // side that applies it. A verifier leaves it unset. + maxDeadlineHorizonMs: + config.masumiMaxDeadlineHorizonMs ?? MASUMI_MAX_DEADLINE_HORIZON_MS, + }, + ); + if (!authorization.ok) { + throw new Error( + `Masumi seller authorization failed: ${authorization.reason}${ + authorization.detail ? ` (${authorization.detail})` : "" + }`, + ); + } + assertMasumiPaymentWindow(masumiExtra, input.maxTimeoutSeconds); + settlementLayer = resolveSettlementLayer(masumiExtra); + masumiBuyerInput = (await config.masumiBuyerInput?.(masumiExtra)) ?? {}; + if ( + masumiBuyerInput.buyerReturnAddress !== undefined && + !isKeyCredentialAddressOn(masumiBuyerInput.buyerReturnAddress, input.network) + ) { + throw new Error( + "Masumi buyer return address must be a key-credential address on network", + ); + } + } + const changeAddress = await client.address(); - const utxos = await client.getWalletUtxos(); + const utxos = await withCardanoProviderTimeout( + client.getWalletUtxos(), + timeoutMs, + "getWalletUtxos", + ); if (utxos.length === 0) { throw new Error("Funding wallet has no UTXOs available for the payment"); } @@ -385,72 +665,107 @@ export function toClientCardanoSigner(config: ClientCardanoSignerConfig): Client const nonceTxHash = Buffer.from(nonceUtxo.transactionId.hash).toString("hex").toLowerCase(); const nonce = `${nonceTxHash}#${Number(nonceUtxo.index)}`; - // Masumi attaches an inline lock datum whose `buyer` must equal the payer - // the facilitator resolves (the nonce input's owner), so derive it from - // that UTXO. The script method attaches the server-supplied inline datum - // verbatim (contract-specific; not verified). Other methods pay a plain - // output. - const extra = input.extra as CardanoExtra | undefined; - const masumiExtra = - extra?.assetTransferMethod === ASSET_TRANSFER_METHOD_MASUMI - ? (extra as CardanoExtraMasumi) - : undefined; + // Masumi attaches an inline lock datum whose `buyer` must control the + // nonce input the facilitator resolves, so derive it from that UTXO. The + // script method attaches the server-supplied inline datum verbatim + // (contract-specific; not verified). Other methods pay a plain output. const scriptExtra = extra?.assetTransferMethod === ASSET_TRANSFER_METHOD_SCRIPT ? (extra as CardanoExtraScript) : undefined; - const masumiDatum = masumiExtra - ? buildMasumiLockInline(masumiExtra, Address.toBech32(nonceUtxo.address)) - : undefined; - const scriptDatum = scriptExtra ? buildScriptDatumInline(scriptExtra) : undefined; - const paymentDatum = masumiDatum ?? scriptDatum; - - let outputAssets = buildOutputAssets(input.asset, BigInt(input.amount)); - // A native-token Masumi lock carries the token plus purely structural - // lovelace, which must cover the post-result min-UTXO and the collateral. - // autoMinUtxo only sizes the current (smaller) datum, so top it up here. - // An ADA lock's requested amount is the lovelace and is validated instead - // by the facilitator against the same floor. - if (masumiExtra && masumiDatum && input.asset.toLowerCase() !== LOVELACE_ASSET) { - const { coinsPerUtxoByte } = await client.getProtocolParameters(); - const datumBytes = Data.toCBORHex(masumiDatum.data).length / 2; - const collateral = masumiExtra.collateralReturnLovelace - ? BigInt(masumiExtra.collateralReturnLovelace) - : 0n; - const floor = masumiMinUtxoLovelace(datumBytes, 1, coinsPerUtxoByte); - const { policyId, assetNameHex } = parseAssetUnit(input.asset); - outputAssets = Assets.addByHex( - Assets.fromLovelace(floor > collateral ? floor : collateral), - policyId, - assetNameHex, - BigInt(input.amount), + + const amount = BigInt(input.amount); + const isLovelace = input.asset.toLowerCase() === LOVELACE_ASSET; + let outputAssets = buildOutputAssets(input.asset, amount); + let paymentDatum = scriptExtra ? buildScriptDatumInline(scriptExtra) : undefined; + + if (masumiExtra) { + // The seller never signs `collateral_return_lovelace`; the client derives + // it from the requested asset and live protocol parameters so the escrow + // still clears min-UTXO after `SubmitResult`. + const { coinsPerUtxoByte } = await withCardanoProviderTimeout( + client.getProtocolParameters(), + timeoutMs, + "getProtocolParameters", ); + const lock = buildMasumiLock( + masumiExtra, + Address.toBech32(nonceUtxo.address), + input.asset, + amount, + coinsPerUtxoByte, + masumiBuyerInput, + ); + const datumView = parseMasumiLockDatum(lock.datum.data); + if (!datumView) { + throw new Error("Masumi client preflight could not decode the lock datum"); + } + const datumInvariants = verifyMasumiDatumInvariants(datumView, input.payTo); + if (!datumInvariants.ok) { + throw new Error( + `Masumi client preflight failed: ${datumInvariants.reason}${ + datumInvariants.detail ? ` (${datumInvariants.detail})` : "" + }`, + ); + } + // The collateral is the buyer's own money and follows the datum size, + // which the seller inflates by padding `reference_key` / + // `reference_signature`. Refuse before signing rather than lock it away + // until `submit_result_time`. + const maxCollateral = + config.masumiMaxCollateralLovelace ?? MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE; + if (lock.collateralLovelace > maxCollateral) { + throw new Error( + `Masumi client preflight failed: collateral ${lock.collateralLovelace} exceeds the ` + + `configured maximum ${maxCollateral}`, + ); + } + paymentDatum = lock.datum; + // The escrow output carries EXACTLY the requested asset set, with + // `lockedLovelace = requestedLovelace + collateral`. + if (isLovelace) { + outputAssets = Assets.fromLovelace(lock.lockedLovelace); + } else { + const { policyId, assetNameHex } = parseAssetUnit(input.asset); + outputAssets = Assets.addByHex( + Assets.fromLovelace(lock.lockedLovelace), + policyId, + assetNameHex, + amount, + ); + } } // Masumi: anchor the tx's validity upper bound to pay_by_time so the lock // can never settle past the deadline (Masumi invalidates a late lock). // Other methods use maxTimeoutSeconds. const ttlMs = masumiExtra - ? BigInt(masumiExtra.payByTime) + ? BigInt(masumiExtra.terms.payByTime) : BigInt(Date.now()) + BigInt(input.maxTimeoutSeconds) * 1000n; - const signBuilder = await client - .newTx() - // .collectFrom() with a specific UTXO ensures the nonce appears as an input (rule 5). - // Additional UTXOs from the wallet may be auto-selected as needed to satisfy the output and fees. - .collectFrom({ inputs: [nonceUtxo] }) - .payToAddress({ - address: Address.fromBech32(input.payTo), - assets: outputAssets, - ...(paymentDatum ? { datum: paymentDatum } : {}), - }) - .setValidity({ to: ttlMs }) - .build({ - changeAddress, - // Bump the output to the protocol min-UTXO for native-asset outputs - // and for datum-bearing outputs (an attached datum raises it). - autoMinUtxo: input.asset.toLowerCase() !== LOVELACE_ASSET || paymentDatum !== undefined, - }); + const signBuilder = await withCardanoProviderTimeout( + client + .newTx() + // .collectFrom() with a specific UTXO ensures the nonce appears as an input (rule 5). + // Additional UTXOs from the wallet may be auto-selected as needed to satisfy the output and fees. + .collectFrom({ inputs: [nonceUtxo] }) + .payToAddress({ + address: Address.fromBech32(input.payTo), + assets: outputAssets, + ...(paymentDatum ? { datum: paymentDatum } : {}), + }) + .setValidity({ to: ttlMs }) + .build({ + changeAddress, + // Bump the output to the protocol min-UTXO for native-asset outputs + // and for datum-bearing outputs (an attached datum raises it). A + // Masumi lock already carries its exact structural lovelace, and + // raising it would break `locked == requested + collateral`. + autoMinUtxo: masumiExtra ? false : !isLovelace || paymentDatum !== undefined, + }), + timeoutMs, + "buildTransaction", + ); const submitBuilder = await signBuilder.sign(); const unsigned = await signBuilder.toTransaction(); @@ -461,14 +776,95 @@ export function toClientCardanoSigner(config: ClientCardanoSignerConfig): Client auxiliaryData: null, }); + if (masumiExtra) { + assertMasumiPayByTimeNotExpired(masumiExtra); + } + + // Client mode: the client broadcasts before the paid retry, and the + // facilitator authenticates that exact transaction instead of submitting + // it. Try to wait until the chain shows it because most providers expose + // no mempool read. A wait failure is still ambiguous after broadcast and + // must not cause the wallet to build a second payment. + if (input.submissionMode === "client") { + const hash = await withCardanoProviderTimeout( + client.submitTx(signed), + timeoutMs, + "submitTx", + ); + // Broadcast already succeeded. Observation failure is ambiguous: the + // transaction can still land, so return the exact signed payload and let + // the paid retry/facilitator report pending evidence instead of forcing + // the caller to build another transaction. + try { + await withCardanoProviderTimeout(client.awaitTx(hash), timeoutMs, "awaitTx"); + } catch { + // Intentionally continue with the original signed transaction. + } + } + return { transaction: Buffer.from(Transaction.toCBORBytes(signed)).toString("base64"), nonce, + submissionMode: input.submissionMode, + ...(settlementLayer ? { settlementLayer } : {}), }; }, }; } +/** + * Ensures the transaction validity bound derived from `payByTime` still lies + * inside the x402 validity window before the wallet is touched. + * + * @param extra - Validated Masumi requirements. + * @param maxTimeoutSeconds - x402 validity-window limit. + */ +function assertMasumiPaymentWindow(extra: CardanoExtraMasumi, maxTimeoutSeconds: number): void { + if (!Number.isSafeInteger(maxTimeoutSeconds) || maxTimeoutSeconds <= 0) { + throw new Error("Masumi maxTimeoutSeconds must be a positive safe integer"); + } + assertMasumiPayByTimeNotExpired(extra); + const payByTime = BigInt(extra.terms.payByTime); + const latestPayByTime = BigInt(Date.now()) + BigInt(maxTimeoutSeconds) * 1000n; + if (payByTime > latestPayByTime) { + throw new Error("Masumi client preflight failed: payByTime exceeds maxTimeoutSeconds"); + } +} + +/** + * Re-checks only the bound that can newly fail between preflight and broadcast. + * + * The `maxTimeoutSeconds` ceiling is deliberately not repeated here: it grows + * with the wall clock, so once it has been cleared it cannot fail later. Expiry + * is the opposite — building and signing takes real time, and a transaction + * whose TTL is already past `pay_by_time` can never settle. + * + * @param extra - Validated Masumi requirements. + */ +function assertMasumiPayByTimeNotExpired(extra: CardanoExtraMasumi): void { + if (BigInt(extra.terms.payByTime) <= BigInt(Date.now())) { + throw new Error("Masumi client preflight failed: payByTime has expired"); + } +} + +/** + * Resolves the settlement layer a Masumi payment will use. + * + * A suitable Hydra head has to be open, verified against its on-chain Init + * state, and bound to the seller's participant key — none of which this signer + * can establish. `auto` therefore resolves to L1 and a policy of `hydra` is + * refused rather than settled on a head that cannot be closed. + * + * @param extra - The validated masumi `extra` block. + * @returns The selected settlement layer. + */ +function resolveSettlementLayer(extra: CardanoExtraMasumi): CardanoSettlementLayer { + if (extra.terms.settlementPolicy === "hydra") { + throw new Error("Masumi terms require Hydra settlement, which this signer cannot provide"); + } + return "l1"; +} + /** * Configuration for the reference {@link toFacilitatorCardanoSigner} factory. */ @@ -500,6 +896,97 @@ export interface FacilitatorCardanoSignerConfig { * scheme rejects mempool-only settlements unless `acceptMempool` is enabled. */ awaitConfirmation?: boolean; + /** + * Complete Cardano ledger phase-1 validation used before server submission. + * The Evolution provider's `evaluateTx` only evaluates Plutus execution and + * is not sufficient. Without this callback, the reference signer does not + * advertise or accept server submission. + */ + validatePhase1Transaction?: (signedTransactionBase64: string, network: string) => Promise; +} + +/** + * Direct Blockfrost REST access for the two queries the Evolution provider + * interface does not expose: authenticated settlement evidence (a + * transaction's canonical depth) and the owner of an already-spent UTXO. + * + * Returns a disabled shim when the signer is configured with another provider; + * the facilitator then cannot authenticate client submission or confirmation + * depths above canonical inclusion. + * + * @param provider - The signer's provider connection config. + * @returns The Blockfrost query helpers. + */ +export function blockfrostQueries(provider: CardanoProviderConfig): { + enabled: boolean; + evidence(txHash: string): Promise; + spentUtxoAddress(txHash: string, index: number): Promise<{ address?: string }>; +} { + const timeoutMs = providerTimeoutMs(provider); + const config = provider.blockfrost; + if (!config) { + return { + enabled: false, + evidence: () => Promise.resolve({ status: "unknown", confirmations: -2 }), + spentUtxoAddress: () => Promise.resolve({}), + }; + } + const baseUrl = config.baseUrl.replace(/\/$/, ""); + const headers = config.projectId ? { project_id: config.projectId } : undefined; + /** + * Performs one Blockfrost GET. + * + * @param path - The API path, starting with a slash. + * @returns The parsed body, or `null` on 404. + */ + const get = async (path: string): Promise | null> => { + const response = await fetch(`${baseUrl}${path}`, { + ...(headers ? { headers } : {}), + signal: AbortSignal.timeout(timeoutMs), + }); + if (response.status === 404) return null; + if (!response.ok) { + throw new Error(`Blockfrost ${path} failed: ${response.status} ${response.statusText}`); + } + return (await response.json()) as Record; + }; + + return { + enabled: true, + + async evidence(txHash: string): Promise { + const tx = await get(`/txs/${txHash}`); + if (!tx) { + // Not on chain. Blockfrost exposes no mempool read, so an unconfirmed + // transaction is indistinguishable from an unknown one. + return { status: "unknown", confirmations: -2 }; + } + // A phase-2-invalid transaction is recorded under this id but consumed its + // collateral instead of its inputs and produced none of its declared + // outputs. It paid nothing, so it is not evidence of settlement. + if (tx.valid_contract === false) { + return { status: "unknown", confirmations: -2 }; + } + const tip = await get("/blocks/latest"); + const txHeight = Number(tx.block_height); + const tipHeight = Number(tip?.height); + if (!Number.isFinite(txHeight) || !Number.isFinite(tipHeight)) { + return { status: "confirmed", confirmations: 0 }; + } + // `confirmations` counts blocks NEWER than the one containing the tx. + return { status: "confirmed", confirmations: Math.max(0, tipHeight - txHeight) }; + }, + + async spentUtxoAddress(txHash: string, index: number): Promise<{ address?: string }> { + const utxos = await get(`/txs/${txHash}/utxos`); + const outputs = (utxos?.outputs ?? []) as Array<{ + output_index?: number; + address?: string; + }>; + const output = outputs.find(o => o.output_index === index); + return output?.address ? { address: output.address } : {}; + }, + }; } /** @@ -513,8 +1000,10 @@ export function toFacilitatorCardanoSigner( config: FacilitatorCardanoSignerConfig, ): FacilitatorCardanoSigner { const chain = resolveChain(config.network); + const timeoutMs = providerTimeoutMs(config.provider); const providerClient = withProvider(Client.make(chain), config.provider); const slotConfig = chain.slotConfig; + const blockfrost = blockfrostQueries(config.provider); // The facilitator only broadcasts the client's already-signed transaction and // queries the chain — both are provider operations. A mnemonic is optional and @@ -550,6 +1039,18 @@ export function toFacilitatorCardanoSigner( return addresses; }, + ...(config.validatePhase1Transaction + ? { + async validatePhase1Transaction( + signedTransactionBase64: string, + network: string, + ): Promise { + assertNetwork(network); + await config.validatePhase1Transaction!(signedTransactionBase64, network); + }, + } + : {}), + async getUtxo(ref: string, network: string): Promise { assertNetwork(network); const { txHash, index } = parseUtxoRef(ref); @@ -557,13 +1058,53 @@ export function toFacilitatorCardanoSigner( transactionId: TransactionHash.fromHex(txHash), index: BigInt(index), }); - const utxos = await client.getUtxosByOutRef([input]); - if (utxos.length === 0) { - return { exists: false }; + const utxos = await withCardanoProviderTimeout( + client.getUtxosByOutRef([input]), + timeoutMs, + "getUtxosByOutRef", + ); + if (utxos.length > 0) { + const utxo = utxos[0]; + const assets: Record = {}; + if (utxo.assets.multiAsset) { + for (const [policyId, innerMap] of utxo.assets.multiAsset.map) { + const policyHex = Buffer.from(policyId.hash).toString("hex").toLowerCase(); + for (const [assetName, quantity] of innerMap) { + const assetNameHex = Buffer.from(assetName.bytes).toString("hex").toLowerCase(); + assets[`${policyHex}.${assetNameHex}`] = quantity; + } + } + } + const address = Address.toBech32(utxo.address); + const paymentCredential = Address.getPaymentCredential(Address.toHex(utxo.address)); + return { + exists: true, + address, + coin: utxo.assets.lovelace, + assets, + ...(paymentCredential?._tag === "KeyHash" + ? { paymentKeyHash: Credential.toHex(paymentCredential).toLowerCase() } + : {}), + }; } - return { exists: true, address: Address.toBech32(utxos[0].address) }; + // Spent (or unknown). Client-submission mode still needs the owner + // address to resolve the payer, so read it from the producing transaction + // when the provider can serve it. + return { exists: false, ...(await blockfrost.spentUtxoAddress(txHash, index)) }; }, + ...(blockfrost.enabled + ? { + async getTransactionEvidence( + txHash: string, + network: string, + ): Promise { + assertNetwork(network); + return blockfrost.evidence(txHash); + }, + } + : {}), + async getCurrentSlot(network: string): Promise { assertNetwork(network); // SlotConfig.zeroTime and slotLength are both in milliseconds for the @@ -579,35 +1120,47 @@ export function toFacilitatorCardanoSigner( network: string, ): Promise { assertNetwork(network); - const tx = Transaction.fromCBORBytes( - Uint8Array.from(Buffer.from(signedTransactionBase64, "base64")), - ); - const hash = await client.submitTx(tx); + const tx = Transaction.fromCBORBytes(decodeCardanoTransactionBytes(signedTransactionBase64)); + const hash = await withCardanoProviderTimeout(client.submitTx(tx), timeoutMs, "submitTx"); const txHash = Buffer.from(hash.hash).toString("hex").toLowerCase(); if (config.awaitConfirmation === false) { return { txHash, status: "mempool" }; } - await client.awaitTx(hash); + // The broadcast already succeeded. A failure while WAITING for inclusion + // must not be reported as a failed submission: the facilitator would then + // treat a transaction that is on its way to the chain as never sent. Report + // mempool acceptance instead and let the confirmation policy decide. + try { + await withCardanoProviderTimeout(client.awaitTx(hash), timeoutMs, "awaitTx"); + } catch { + return { txHash, status: "mempool" }; + } return { txHash, status: "confirmed" }; }, async waitForConfirmation(txHash: string, network: string): Promise { assertNetwork(network); - await client.awaitTx(TransactionHash.fromHex(txHash)); + await withCardanoProviderTimeout( + client.awaitTx(TransactionHash.fromHex(txHash)), + timeoutMs, + "awaitTx", + ); }, async evaluateTransaction(signedTransactionBase64: string, network: string): Promise { assertNetwork(network); - const tx = Transaction.fromCBORBytes( - Uint8Array.from(Buffer.from(signedTransactionBase64, "base64")), - ); - await client.evaluateTx(tx); + const tx = Transaction.fromCBORBytes(decodeCardanoTransactionBytes(signedTransactionBase64)); + await withCardanoProviderTimeout(client.evaluateTx(tx), timeoutMs, "evaluateTx"); }, async getCoinsPerUtxoByte(network: string): Promise { assertNetwork(network); if (coinsPerUtxoByte === undefined) { - const params = await client.getProtocolParameters(); + const params = await withCardanoProviderTimeout( + client.getProtocolParameters(), + timeoutMs, + "getProtocolParameters", + ); coinsPerUtxoByte = params.coinsPerUtxoByte; } return coinsPerUtxoByte; diff --git a/typescript/packages/mechanisms/cardano/src/types.ts b/typescript/packages/mechanisms/cardano/src/types.ts index c9aaad4de3..c90eeee388 100644 --- a/typescript/packages/mechanisms/cardano/src/types.ts +++ b/typescript/packages/mechanisms/cardano/src/types.ts @@ -1,3 +1,31 @@ +/** + * Who broadcasts the signed transaction. Declared by the server in + * `PaymentRequirements.extra.submissionPolicy`; `either` lets the client pick. + */ +export type CardanoSubmissionPolicy = "server" | "client" | "either"; + +/** + * The mode a paid payload actually selected. An absent `payload.submissionMode` + * normalizes to `server`; `either` is a policy and never a payload mode. + */ +export type CardanoSubmissionMode = "server" | "client"; + +/** + * Ledger a Masumi payment settles on. Carried by `payload.settlementLayer`. + */ +export type CardanoSettlementLayer = "l1" | "hydra"; + +/** + * Minimum L1 evidence required before the resource is released. + * + * `-1` is authenticated mempool acceptance, `0` is inclusion in a canonical + * block, and `1..20` requires that many newer canonical blocks. Greater + * evidence satisfies a lower threshold. + */ +export interface CardanoConfirmationPolicy { + l1Confirmations: number; +} + /** * Payload structure carried inside a Cardano `exact` PaymentPayload. * @@ -15,15 +43,44 @@ export type ExactCardanoPayload = { * UTXO reference (`txHash#index`) used as nonce, must be present as a tx input. */ nonce: string; + /** + * Who broadcasts. Absent normalizes to `server`; the normalized value MUST be + * allowed by the selected `extra.submissionPolicy`. + */ + submissionMode?: CardanoSubmissionMode; + /** + * Masumi only: the ledger this payment settles on. `terms.settlementPolicy` + * MUST allow the selected value. + */ + settlementLayer?: CardanoSettlementLayer; + /** + * Masumi + Hydra only: the canonical lowercase 56-character hexadecimal Hydra + * protocol head id from the on-chain Init transaction. MUST be absent for L1. + */ + headId?: string; }; +/** + * Fields every Cardano `extra` block may carry, whatever the transfer method. + */ +export interface CardanoExtraPolicies { + /** + * Who broadcasts the signed transaction. Defaults to `server` when absent. + */ + submissionPolicy?: CardanoSubmissionPolicy; + /** + * Minimum L1 evidence. Defaults to `{ l1Confirmations: 1 }` when absent. + */ + confirmationPolicy?: CardanoConfirmationPolicy; +} + /** * Common (default) `extra` shape for Cardano payment requirements. * * The default assetTransferMethod is the address-to-address flow described in * the spec — `extra` may be empty or carry caller-defined metadata. */ -export interface CardanoExtraDefault { +export interface CardanoExtraDefault extends CardanoExtraPolicies { /** * Free-form metadata. Implementations MUST tolerate unknown keys. */ @@ -35,90 +92,143 @@ export interface CardanoExtraDefault { } /** - * `extra` shape for the Masumi assetTransferMethod — the fields needed to build - * the Masumi `vested_pay` escrow lock datum. Field semantics follow the spec - * section "Masumi assetTransferMethod Schema"; each maps to a datum field. + * One part of the Masumi request commitment. `partBytes` is + * `UTF-8(RFC8785-JCS(content))` for `jcs` and `base64url-decode(content)` for + * `raw`; `digest` is their lowercase hex SHA-256. * - * The identifier and time-bound fields are **required**: in x402 the resource - * server obtains them from the Masumi purchase and MUST supply them here. They - * bind the locked UTxO to the purchase off-chain — the client must not invent - * them (a wrong/random value produces an escrow the Masumi Payment Service - * cannot match to the purchase). Only fields with a contract-defined default - * (`inputHash` → empty, `collateralReturnLovelace` → 0) and the optional return - * addresses are optional. + * `content` is REQUIRED only for parts the issuer originates. It is OPTIONAL + * for parts derived from the client's own request bytes, which the client + * recomputes from what it sent — the manifest excludes `content` by + * construction, so omitting it on the wire does not change `inputHash`. */ -export interface CardanoExtraMasumi { - /** - * Free-form additional metadata. - */ - [key: string]: unknown; - /** - * Method marker selecting Masumi semantics. - */ - assetTransferMethod: "masumi"; - /** - * Masumi `PaymentSourceType` — selects the contract generation - * (`Web3CardanoV2` = the `vested_pay` payment-v2 escrow). - */ - paymentType?: string; - /** - * Escrow (`vested_pay`) script address for this Masumi deployment, from the - * purchase. **Required** and must equal `payTo`: a script address is - * deployment-specific (its parameters are baked into the hash), so it cannot - * be safely defaulted — locking to a wrong escrow silently strands the funds. - * For Masumi's canonical deployment, {@link masumiContractAddress} computes it. - */ - contractAddress: string; - /** - * Full seller address (public-key credential); datum `seller`. - */ +export interface MasumiCommitmentPart { + /** Unique non-empty part name (conventionally `parameters`, `body`, `raw`). */ + name: string; + /** How `content` is turned into bytes. */ + canonicalization: "jcs" | "raw"; + /** Optional media type, preserved byte-for-byte in the manifest. */ + mediaType?: string; + /** RFC 8785-compatible JSON for `jcs`, unpadded base64url string for `raw`. */ + content?: unknown; + /** Lowercase hex `SHA-256(partBytes)`, exactly 64 characters. */ + digest: string; +} + +/** + * The Masumi request commitment. Its `digest` is what the escrow's `input_hash` + * binds the locked funds to, tying the payment to exactly the job requested. + */ +export interface MasumiInputCommitment { + /** Literal `"1"`. */ + version: string; + /** Literal `"sha256"`. */ + algorithm: string; + /** Ordered parts with unique `name` values. */ + parts: MasumiCommitmentPart[]; + /** Lowercase hex digest over the content-free manifest; equals `terms.inputHash`. */ + digest: string; +} + +/** + * The seller-signed terms. Projected into `signedTerms` together with the + * top-level `PaymentRequirements` fields and hashed into `termsDigest`, which + * the seller authorizes with a CIP-8 `COSE_Sign1`. + * + * This is a **closed object**: an unknown field is invalid, and it MUST NOT + * repeat a field that is projected from the top level. + */ +export interface MasumiTerms { + /** Literal `"1"`. */ + version: string; + /** Literal `"Web3CardanoV2"` — selects the contract generation. */ + paymentType: string; + /** Key-credential seller address on the selected network; datum `seller`. */ sellerAddress: string; /** - * Optional payout destination; datum `seller_return_address`. + * Optional key-credential payout address; datum `seller_return_address`. + * **Omitted** when absent — JSON `null` is invalid. */ sellerReturnAddress?: string; + /** Exactly 32 fresh cryptographically random bytes as 64 lowercase hex characters. */ + sellerNonce: string; + /** Empty string, or 7–13 bytes as 14–26 lowercase hex characters. */ + buyerNonce: string; /** - * Optional payout destination; datum `buyer_return_address`. + * Registry asset identifier. Omitted, `null` or empty means the seller is + * unregistered; these are distinct signed wire values and MUST be + * reconstructed verbatim into `signedTerms`. */ - buyerReturnAddress?: string; + agentIdentifier?: string | null; + /** Exactly equal to `inputCommitment.digest`. */ + inputHash: string; + /** POSIX millisecond deadlines as positive canonical base-10 strings. */ + payByTime: string; + submitResultTime: string; + unlockTime: string; + externalDisputeUnlockTime: string; + /** Which ledger the payment may settle on. */ + settlementPolicy: "auto" | "l1" | "hydra"; +} + +/** + * Validator parameters baked into the escrow script hash. Omit for the + * canonical deployment; when present they replace **only** these three applied + * parameters against the same canonical compiled validator. + */ +export interface MasumiDeployment { + /** Positive canonical base-10 integer string, at most `adminVkeys.length`. */ + requiredAdmins: string; /** - * datum `reference_key` (hex) — from the Masumi purchase. + * Ordered non-empty 28-byte lowercase hex key hashes. Duplicates are + * preserved and carry voting weight — a key appearing *n* times counts *n* + * times toward the threshold. */ - referenceKey: string; + adminVkeys: string[]; + /** Non-negative canonical base-10 POSIX-millisecond integer string. */ + cooldownPeriod: string; +} + +/** + * `extra` shape for the Masumi assetTransferMethod — everything a client needs + * to build the `vested_pay` escrow lock and a facilitator needs to verify it. + * + * This is a **closed object**: an unknown field is invalid. `payTo` is not + * repeated here; it is signed into `signedTerms` as `contractAddress` and the + * verifier re-derives the escrow address from {@link MasumiDeployment} and + * requires it to equal `payTo`. `collateral_return_lovelace` is deliberately + * absent — the client computes it from the final transaction (see the spec's + * "Escrow datum and client-computed collateral"). + */ +export interface CardanoExtraMasumi extends CardanoExtraPolicies { /** - * datum `reference_signature` (hex; >= 16 bytes) — from the Masumi purchase. + * Method marker selecting Masumi semantics. */ - referenceSignature: string; + assetTransferMethod: "masumi"; /** - * datum `buyer_nonce` (hex) — from the Masumi purchase. + * The request commitment whose digest equals `terms.inputHash`. */ - identifierFromPurchaser: string; + inputCommitment: MasumiInputCommitment; /** - * datum `seller_nonce` (hex) — from the Masumi purchase. + * The seller-signed terms. */ - sellerNonce: string; + terms: MasumiTerms; /** - * datum `agent_identifier` (hex) — the registered Masumi agent. + * Complete CBOR `COSE_Key` as lowercase hex; datum `reference_key`. */ - agentIdentifier: string; + referenceKey: string; /** - * datum `input_hash` (hex). Optional; defaults to empty when absent. + * Complete CBOR `COSE_Sign1` as lowercase hex; datum `reference_signature`. */ - inputHash?: string; + referenceSignature: string; /** - * datum `collateral_return_lovelace` (decimal string; >= 0). Optional; - * defaults to 0 when absent. + * Lowercase hex of the LZString-compressed Masumi compatibility identifier. */ - collateralReturnLovelace?: string; + blockchainIdentifier: string; /** - * Time bounds as POSIX **milliseconds** (decimal strings) from the Masumi - * purchase, ordered `payByTime <= submitResultTime <= unlockTime <= - * externalDisputeUnlockTime`. + * Optional non-canonical validator parameterization. Preview has no canonical + * default and therefore requires this field. */ - payByTime: string; - submitResultTime: string; - unlockTime: string; - externalDisputeUnlockTime: string; + deployment?: MasumiDeployment; } /** @@ -152,7 +262,7 @@ export interface CardanoScriptParameter { /** * `extra` shape for the script assetTransferMethod. */ -export interface CardanoExtraScript { +export interface CardanoExtraScript extends CardanoExtraPolicies { /** * Free-form additional metadata. */ @@ -259,6 +369,14 @@ export interface DecodedCardanoTransaction { * Transaction inputs as ordered UTXO references (`txHashHex#index`). */ inputs: string[]; + /** Transaction fee in lovelace. */ + fee: bigint; + /** + * Balance-changing operations outside a plain payment. The reference + * facilitator rejects these before server submission because it cannot + * prove value conservation from payment inputs and outputs alone. + */ + unsupportedPhase1Operations: string[]; /** * Decoded outputs in declaration order. */ @@ -268,18 +386,37 @@ export interface DecodedCardanoTransaction { * the facilitator to refuse unsigned transactions in `verify()`. */ vkeyWitnessCount: number; + /** + * Lowercase hex of every vkey witness public key, so the verifier can confirm + * the buyer credential named by a Masumi datum actually witnessed the tx. + */ + vkeyHashes: string[]; /** * Number of script witnesses (native + plutus) present. A script-mode * payment must carry at least one redeemer; for default/Masumi payments * either vkey or bootstrap witnesses suffice. */ scriptWitnessCount: number; + /** + * Number of Plutus redeemers. Only a transaction that runs a Plutus script + * can be phase-2 invalid, so a payment with none can never land as a + * failed-script transaction that creates no outputs. + */ + redeemerCount: number; /** * True when every vkey witness carries a valid Ed25519 signature over the * transaction body hash. False signals a forged or stale signature that the * chain would reject at submission. */ signaturesValid: boolean; + /** + * The transaction's ledger `is_valid` flag. When `false` the transaction is a + * *failed script* transaction: the ledger consumes its collateral instead of + * its inputs and creates **none** of its declared outputs — so the payment + * output it advertises never exists, even though the transaction lands under + * this exact transaction id. + */ + isValid: boolean; /** * Index of the auxiliary data hash, if any (kept for parity with future * additions; unused today). diff --git a/typescript/packages/mechanisms/cardano/src/utils.ts b/typescript/packages/mechanisms/cardano/src/utils.ts index da7b495386..2f915d513f 100644 --- a/typescript/packages/mechanisms/cardano/src/utils.ts +++ b/typescript/packages/mechanisms/cardano/src/utils.ts @@ -2,6 +2,7 @@ import { Address, Data, Ed25519Signature, + KeyHash, SlotConfig, Transaction, TransactionBody, @@ -19,7 +20,14 @@ import { CARDANO_UTXO_REF_REGEX, normalizeCardanoNetwork, } from "./constants"; -import type { CardanoUtxoOutput, DecodedCardanoTransaction, ExactCardanoPayload } from "./types"; +import { normalizeSubmissionMode } from "./policy"; +import { MAX_CARDANO_TRANSACTION_BYTES } from "./limits"; +import type { + CardanoSettlementLayer, + CardanoUtxoOutput, + DecodedCardanoTransaction, + ExactCardanoPayload, +} from "./types"; /** * Protocol minimum lovelace for a transaction output of the given CBOR-serialized @@ -57,6 +65,40 @@ export function slotToPosixMs(network: string, slot: bigint): number { return Number(cfg.zeroTime) + (Number(slot) - Number(cfg.zeroSlot)) * cfg.slotLength; } +/** + * Strips a single definite-length CBOR byte-string wrapper, returning the + * payload bytes. `UPLC.applyParamsToScript` returns the script wrapped in one + * such layer; a script hash is computed over the unwrapped bytes. + * + * @param hex - Hex of a definite-length CBOR byte string. + * @returns The unwrapped payload bytes. + */ +export function unwrapCborByteString(hex: string): Uint8Array { + const bytes = Buffer.from(hex, "hex"); + if (bytes.length === 0 || bytes[0] >> 5 !== 2) { + throw new Error("Expected a CBOR byte string from applyParamsToScript"); + } + const additional = bytes[0] & 0x1f; + let length: number; + let offset: number; + if (additional < 24) { + length = additional; + offset = 1; + } else if (additional === 24) { + length = bytes[1]; + offset = 2; + } else if (additional === 25) { + length = bytes.readUInt16BE(1); + offset = 3; + } else if (additional === 26) { + length = bytes.readUInt32BE(1); + offset = 5; + } else { + throw new Error("Unsupported CBOR byte-string length encoding"); + } + return Uint8Array.from(bytes.subarray(offset, offset + length)); +} + /** * Splits a Cardano asset unit (`policyId.assetNameHex`) into its components. * @@ -88,9 +130,16 @@ export function parseUtxoRef(ref: string): { txHash: string; index: number } { return { txHash: txHash.toLowerCase(), index: parseInt(indexStr, 10) }; } +/** Canonical Hydra protocol head id: 56 lowercase hex characters. */ +const HYDRA_HEAD_ID_REGEX = /^[0-9a-f]{56}$/; + /** * Reads a Cardano payment payload back out of an arbitrary record. * + * `submissionMode` is left un-normalized here so the facilitator can tell an + * absent value (which normalizes to `server`) from an invalid one; the optional + * settlement fields are validated structurally. + * * @param raw - The raw payload coming from the x402 envelope. * @returns The typed Cardano payload. */ @@ -100,10 +149,39 @@ export function decodeCardanoPayload(raw: Record): ExactCardano if (typeof transaction !== "string" || transaction.length === 0) { throw new Error("Cardano payload is missing a transaction string"); } + if (transaction.length > Math.ceil(MAX_CARDANO_TRANSACTION_BYTES / 3) * 4) { + throw new Error("Cardano payload transaction exceeds the decode limit"); + } if (typeof nonce !== "string" || nonce.length === 0) { throw new Error("Cardano payload is missing a nonce string"); } - return { transaction, nonce }; + if (raw.submissionMode !== undefined && normalizeSubmissionMode(raw.submissionMode) === null) { + throw new Error(`Cardano payload has an invalid submissionMode: ${String(raw.submissionMode)}`); + } + const settlementLayer = raw.settlementLayer; + if (settlementLayer !== undefined && settlementLayer !== "l1" && settlementLayer !== "hydra") { + throw new Error(`Cardano payload has an invalid settlementLayer: ${String(settlementLayer)}`); + } + const headId = raw.headId; + if (headId !== undefined && (typeof headId !== "string" || !HYDRA_HEAD_ID_REGEX.test(headId))) { + throw new Error("Cardano payload headId must be 56 lowercase hex characters"); + } + // A Hydra head id only has meaning for a Hydra payment; carrying one on an L1 + // payload signals a client that mixed the two settlement paths. + if (headId !== undefined && settlementLayer !== "hydra") { + throw new Error("Cardano payload headId is only valid with settlementLayer 'hydra'"); + } + return { + transaction, + nonce, + ...(raw.submissionMode !== undefined + ? { submissionMode: raw.submissionMode as ExactCardanoPayload["submissionMode"] } + : {}), + ...(settlementLayer !== undefined + ? { settlementLayer: settlementLayer as CardanoSettlementLayer } + : {}), + ...(headId !== undefined ? { headId } : {}), + }; } /** @@ -115,7 +193,7 @@ export function decodeCardanoPayload(raw: Record): ExactCardano * @returns The decoded transaction summary. */ export function decodeCardanoTransaction(transactionBase64: string): DecodedCardanoTransaction { - const txBytes = Uint8Array.from(Buffer.from(transactionBase64, "base64")); + const txBytes = decodeCardanoTransactionBytes(transactionBase64); const tx = Transaction.fromCBORBytes(txBytes); // Hash the raw CBOR body bytes (blake2b-256). `extractBodyBytes` preserves @@ -162,12 +240,18 @@ export function decodeCardanoTransaction(transactionBase64: string): DecodedCard const ws = tx.witnessSet; const vkeyWitnessCount = (ws.vkeyWitnesses?.length ?? 0) + (ws.bootstrapWitnesses?.length ?? 0); + // Blake2b-224 of each witness key: the Masumi lock invariants require the + // datum's `buyer` payment credential to have actually witnessed the tx. + const vkeyHashes = (ws.vkeyWitnesses ?? []).map(witness => + KeyHash.toHex(KeyHash.fromVKey(witness.vkey)).toLowerCase(), + ); + const redeemerCount = ws.redeemers ? ws.redeemers.size : 0; const scriptWitnessCount = (ws.nativeScripts?.length ?? 0) + (ws.plutusV1Scripts?.length ?? 0) + (ws.plutusV2Scripts?.length ?? 0) + (ws.plutusV3Scripts?.length ?? 0) + - (ws.redeemers ? ws.redeemers.size : 0); + redeemerCount; // Cryptographically verify every vkey witness signs the body hash, so verify() // can reject a transaction carrying a forged or stale signature that could @@ -182,9 +266,54 @@ export function decodeCardanoTransaction(transactionBase64: string): DecodedCard ttlSlot: tx.body.ttl, validityStartSlot: tx.body.validityIntervalStart, inputs, + fee: tx.body.fee, + unsupportedPhase1Operations: [ + ...(tx.body.auxiliaryDataHash ? ["auxiliaryData"] : []), + ...(tx.body.certificates ? ["certificates"] : []), + ...(tx.body.withdrawals ? ["withdrawals"] : []), + ...(tx.body.mint ? ["mint"] : []), + ...(tx.body.scriptDataHash ? ["scriptData"] : []), + ...(tx.body.collateralInputs ? ["collateralInputs"] : []), + ...(tx.body.requiredSigners ? ["requiredSigners"] : []), + ...(tx.body.collateralReturn ? ["collateralReturn"] : []), + ...(tx.body.totalCollateral !== undefined ? ["totalCollateral"] : []), + ...(tx.body.referenceInputs ? ["referenceInputs"] : []), + ...(tx.body.votingProcedures ? ["votingProcedures"] : []), + ...(tx.body.proposalProcedures ? ["proposalProcedures"] : []), + ...(tx.body.currentTreasuryValue !== undefined ? ["currentTreasuryValue"] : []), + ...(tx.body.donation !== undefined ? ["donation"] : []), + ], outputs, vkeyWitnessCount, + vkeyHashes, scriptWitnessCount, + redeemerCount, signaturesValid, + isValid: tx.isValid !== false, }; } + +/** + * Strictly decodes canonical base64 transaction bytes under the local resource + * budget. Node's permissive base64 decoder alone would ignore malformed input. + * + * @param transactionBase64 - Canonical padded base64 transaction CBOR. + * @returns Decoded transaction bytes. + */ +export function decodeCardanoTransactionBytes(transactionBase64: string): Uint8Array { + if ( + transactionBase64.length === 0 || + transactionBase64.length > Math.ceil(MAX_CARDANO_TRANSACTION_BYTES / 3) * 4 + ) { + throw new Error("Cardano transaction exceeds the decode limit"); + } + const decoded = Buffer.from(transactionBase64, "base64"); + if ( + decoded.length === 0 || + decoded.length > MAX_CARDANO_TRANSACTION_BYTES || + decoded.toString("base64") !== transactionBase64 + ) { + throw new Error("Cardano transaction must use canonical padded base64"); + } + return Uint8Array.from(decoded); +} diff --git a/typescript/packages/mechanisms/cardano/test/helpers/buildSignedTx.ts b/typescript/packages/mechanisms/cardano/test/helpers/buildSignedTx.ts index 8de59df57b..9cb895a577 100644 --- a/typescript/packages/mechanisms/cardano/test/helpers/buildSignedTx.ts +++ b/typescript/packages/mechanisms/cardano/test/helpers/buildSignedTx.ts @@ -16,6 +16,7 @@ import { Address, Assets, Client, + Credential, mainnet, InlineDatum, preprod, @@ -34,6 +35,40 @@ import { LOVELACE_ASSET, } from "../../src/constants"; import { parseAssetUnit } from "../../src/utils"; +import type { CardanoUtxoSnapshot } from "../../src/signer"; + +/** Input snapshots registered by offline fixtures for the in-memory chain. */ +const fixtureInputSnapshots = new Map(); + +/** + * Returns the latest authenticated input snapshot registered by a fixture. + * + * @param ref - UTXO reference. + * @returns Snapshot, if a fixture registered it. + */ +export function getFixtureInputSnapshot(ref: string): CardanoUtxoSnapshot | undefined { + return fixtureInputSnapshots.get(ref.toLowerCase()); +} + +/** + * Converts Evolution assets into the facilitator snapshot shape. + * + * @param value - Evolution SDK value. + * @returns Canonical native-asset map. + */ +function snapshotAssets(value: Assets.Assets): Record { + const result: Record = {}; + if (value.multiAsset) { + for (const [policyId, innerMap] of value.multiAsset.map) { + const policyHex = Buffer.from(policyId.hash).toString("hex").toLowerCase(); + for (const [assetName, quantity] of innerMap) { + const assetNameHex = Buffer.from(assetName.bytes).toString("hex").toLowerCase(); + result[`${policyHex}.${assetNameHex}`] = quantity; + } + } + } + return result; +} /** * Minimal protocol parameters sufficient for an offline fee calculation. These @@ -85,6 +120,18 @@ export interface BuildSignedTxParams { secondInput?: { ref: string; lovelace: bigint }; /** Optional inline datum to attach to the payment output (e.g. a Masumi lock). */ datum?: InlineDatum.InlineDatum; + /** + * Optional wallet mnemonic. Supply one when the caller needs the payer address + * *before* building the transaction — a Masumi lock datum names the buyer, and + * the buyer must control the nonce input. + */ + mnemonic?: string; + /** + * Exact lovelace to place on the payment output, disabling the automatic + * min-UTXO bump. A Masumi lock must carry exactly + * `requestedLovelace + collateral`, which the bump would break. + */ + outputLovelace?: bigint; } /** @@ -97,6 +144,8 @@ export interface BuildSignedTxResult { nonce: string; /** The payer bech32 address that controls the nonce UTXO. */ payer: string; + /** Authenticated snapshot registered for the nonce input. */ + nonceSnapshot: CardanoUtxoSnapshot; } /** @@ -128,7 +177,7 @@ export async function buildSignedTx(params: BuildSignedTxParams): Promise { const { policyId, assetNameHex } = parseAssetUnit(params.asset); - return Assets.addByHex(Assets.zero, policyId, assetNameHex, params.amount); + return Assets.addByHex( + params.outputLovelace !== undefined + ? Assets.fromLovelace(params.outputLovelace) + : Assets.zero, + policyId, + assetNameHex, + params.amount, + ); })(); const signBuilder = await client @@ -199,7 +275,7 @@ export async function buildSignedTx(params: BuildSignedTxParams): Promise>; + buyerNonce?: string; + agentIdentifier?: string | null; + settlementPolicy?: MasumiTerms["settlementPolicy"]; + sellerReturnAddress?: string; + submissionPolicy?: CardanoExtraMasumi["submissionPolicy"]; + confirmationPolicy?: CardanoExtraMasumi["confirmationPolicy"]; + deployment?: MasumiDeployment; +} + +/** A complete Masumi 402 plus the seller that authorized it. */ +export interface IssuedMasumiRequirements { + requirements: PaymentRequirements; + extra: CardanoExtraMasumi; + sellerAddress: string; +} + +/** Minimum deadline gaps the spec requires, in POSIX milliseconds. */ +const FIVE_MINUTES = 5n * 60n * 1000n; +const FIFTEEN_MINUTES = 15n * 60n * 1000n; + +/** + * Derives a fresh key-credential (enterprise) address on a network. + * + * @param network - The x402 Cardano network identifier. + * @returns The private key and its bech32 enterprise address. + */ +export function freshKeyAddress(network: string): { + privateKey: PrivateKey.PrivateKey; + address: string; +} { + const privateKey = PrivateKey.fromBytes(PrivateKey.generate()); + const keyHash = KeyHash.fromVKey(VKey.fromPrivateKey(privateKey)); + const enterprise = new EnterpriseAddress.EnterpriseAddress({ + networkId: getCardanoNetworkId(network), + paymentCredential: keyHash, + }); + return { privateKey, address: Address.toBech32(enterprise as unknown as Address.Address) }; +} + +/** + * Issues a spec-conformant Masumi 402 signed by a throwaway seller key. + * + * @param options - What to issue. + * @returns The requirements, the typed extra, and the seller address. + */ +export async function issueMasumiRequirements( + options: IssueMasumiOptions, +): Promise { + const { privateKey, address: sellerAddress } = freshKeyAddress(options.network); + const payByTime = options.payByTimeMs; + const submitResultTime = options.submitResultTimeMs ?? payByTime + FIVE_MINUTES; + const unlockTime = options.unlockTimeMs ?? submitResultTime + FIFTEEN_MINUTES; + const externalDisputeUnlockTime = + options.externalDisputeUnlockTimeMs ?? unlockTime + FIFTEEN_MINUTES; + + const requirements = await issue({ + network: options.network, + asset: options.asset, + amount: options.amount, + maxTimeoutSeconds: options.maxTimeoutSeconds ?? 600, + sellerAddress, + commitment: options.parts ?? [ + { + name: "body", + canonicalization: "jcs", + mediaType: "application/json", + content: { days: 3, units: "metric" }, + }, + ], + payByTime: payByTime.toString(), + submitResultTime: submitResultTime.toString(), + unlockTime: unlockTime.toString(), + externalDisputeUnlockTime: externalDisputeUnlockTime.toString(), + // Fixed so a fixture's blockchainIdentifier is reproducible. + sellerNonce: "ab".repeat(32), + // Negative fixtures exist precisely to mint 402s the issuer's own policy + // rejects — hostile sellers do not run our issuer. `issue.spec` covers the + // issuer-side checks directly. + unsafeSkipPolicyChecks: true, + ...(options.buyerNonce !== undefined ? { buyerNonce: options.buyerNonce } : {}), + ...(options.agentIdentifier !== undefined ? { agentIdentifier: options.agentIdentifier } : {}), + ...(options.sellerReturnAddress !== undefined + ? { sellerReturnAddress: options.sellerReturnAddress } + : {}), + ...(options.settlementPolicy ? { settlementPolicy: options.settlementPolicy } : {}), + ...(options.submissionPolicy ? { submissionPolicy: options.submissionPolicy } : {}), + ...(options.confirmationPolicy ? { confirmationPolicy: options.confirmationPolicy } : {}), + ...(options.deployment ? { deployment: options.deployment } : {}), + signTerms: (address, termsDigestHex) => { + const signed = COSE.SignData.signData( + Address.toHex(Address.fromBech32(address)), + Uint8Array.from(Buffer.from(termsDigestHex, "hex")), + privateKey, + ); + return { + key: Buffer.from(signed.key).toString("hex").toLowerCase(), + signature: Buffer.from(signed.signature).toString("hex").toLowerCase(), + }; + }, + }); + + return { + requirements, + extra: requirements.extra as unknown as CardanoExtraMasumi, + sellerAddress, + }; +} diff --git a/typescript/packages/mechanisms/cardano/test/helpers/stubs.ts b/typescript/packages/mechanisms/cardano/test/helpers/stubs.ts index 7f12166596..57d2df89ea 100644 --- a/typescript/packages/mechanisms/cardano/test/helpers/stubs.ts +++ b/typescript/packages/mechanisms/cardano/test/helpers/stubs.ts @@ -17,23 +17,58 @@ import { import type { Network, PaymentRequirements } from "@x402/core/types"; import { + ASSET_TRANSFER_METHOD_MASUMI, ASSET_TRANSFER_METHOD_SCRIPT, CARDANO_PREPROD_CAIP2, LOVELACE_ASSET, } from "../../src/constants"; +import { buildMasumiLock } from "../../src/exact/masumi/lock"; +import { validateMasumiExtra } from "../../src/exact/masumi/schema"; import { buildScriptDatumInline } from "../../src/exact/script/datum"; import type { ClientCardanoSigner, FacilitatorCardanoSigner } from "../../src/signer"; import type { CardanoExtraScript } from "../../src/types"; -import { buildSignedTx } from "./buildSignedTx"; +import { decodeCardanoTransaction } from "../../src/utils"; +import { buildSignedTx, getFixtureInputSnapshot } from "./buildSignedTx"; + +/** `coinsPerUtxoByte` used by the offline fixtures (current mainnet value). */ +export const STUB_COINS_PER_UTXO_BYTE = 4310n; + +/** One buyer wallet per process, so every stub Masumi lock names the same buyer. */ +const STUB_BUYER_MNEMONIC = PrivateKey.generateMnemonic(); + +/** + * The bech32 address of the wallet {@link stubClientSigner} uses for Masumi + * locks. The datum names it as `buyer`, so the facilitator's `getUtxo` stub must + * report it as the nonce UTXO's owner. + * + * @returns The buyer bech32 address. + */ +export async function stubBuyerAddress(): Promise { + return Address.toBech32( + await Client.make(preprod).withSeed({ mnemonic: STUB_BUYER_MNEMONIC }).address(), + ); +} /** Network used across the deterministic suites. */ export const NETWORK: Network = CARDANO_PREPROD_CAIP2; -/** TTL slot comfortably ahead of {@link STUB_CURRENT_SLOT}. */ -export const TTL_SLOT = 200_000_000n; +/** + * Current slot reported by the stub chain layer. Derived from the real clock so + * fixtures stay consistent with wall-clock-based logic (TTL retention windows, + * Masumi deadlines) instead of drifting into the past as time passes. + */ +export const STUB_CURRENT_SLOT = + preprod.slotConfig.zeroSlot + + BigInt( + Math.floor((Date.now() - Number(preprod.slotConfig.zeroTime)) / preprod.slotConfig.slotLength), + ); -/** Current slot reported by the stub chain layer (below {@link TTL_SLOT}). */ -export const STUB_CURRENT_SLOT = 100_000_000n; +/** + * TTL slot ahead of {@link STUB_CURRENT_SLOT} but still inside the fixtures' + * 600-second `maxTimeoutSeconds`, so it satisfies rule 7's upper bound. Preprod + * slots are one second long. + */ +export const TTL_SLOT = STUB_CURRENT_SLOT + 300n; /** Fixed nonce UTXO reference forced into every fixture transaction. */ export const NONCE_REF = `${"a".repeat(64)}#0`; @@ -62,15 +97,75 @@ export async function freshPreprodAddress(): Promise { export function stubFacilitatorSigner( overrides: Partial = {}, ): FacilitatorCardanoSigner { + // A transaction only becomes evidence once it has been submitted, exactly as + // on a real chain. A stub that reported every transaction as already included + // would silently disable the checks that only apply before acceptance. + const submitted = new Set(); return { getAddresses: () => [PAYER_ADDRESS], - getUtxo: async () => ({ exists: true, address: PAYER_ADDRESS }), + getUtxo: async ref => getFixtureInputSnapshot(ref) ?? { exists: true, address: PAYER_ADDRESS }, + // Fixtures are built by the real transaction builder. Model the complete + // ledger preflight that production server-submission signers must provide. + validatePhase1Transaction: async () => undefined, getCurrentSlot: async () => STUB_CURRENT_SLOT, - submitTransaction: async () => ({ txHash: "f".repeat(64), status: "confirmed" }), + submitTransaction: async transaction => { + const { txHash } = decodeCardanoTransaction(transaction); + submitted.add(txHash); + return { txHash, status: "confirmed" }; + }, + // One newer canonical block: satisfies the default confirmationPolicy. + getTransactionEvidence: async txHash => + submitted.has(txHash) + ? { status: "confirmed", confirmations: 1 } + : { status: "unknown", confirmations: -2 }, ...overrides, }; } +/** + * Builds a real signed Masumi escrow lock offline, exactly as the reference + * client signer would: the buyer controls the nonce input, the datum comes from + * the seller-signed terms, and the escrow output carries precisely + * `requestedLovelace + collateral`. + * + * Exposed so a test can build a *second, different* lock for the same 402 (by + * varying the nonce) and exercise the Masumi logical-replay guard. + * + * @param extra - The masumi `extra` block from the requirements. + * @param network - The x402 Cardano network identifier. + * @param payTo - The escrow address. + * @param asset - The requested asset unit. + * @param amount - The requested amount. + * @param nonceUtxoRef - The UTXO reference to consume as nonce. + * @returns The base64 transaction and its nonce. + */ +export async function buildStubMasumiLockTx( + extra: Record, + network: string, + payTo: string, + asset: string, + amount: bigint, + nonceUtxoRef: string, +): Promise<{ transaction: string; nonce: string }> { + const schema = validateMasumiExtra(extra, network); + if (!schema.ok) throw new Error(`invalid masumi extra: ${schema.detail}`); + const buyer = await stubBuyerAddress(); + const lock = buildMasumiLock(schema.extra, buyer, asset, amount, STUB_COINS_PER_UTXO_BYTE); + const built = await buildSignedTx({ + payTo, + asset, + amount, + nonceUtxoRef, + ttlSlot: TTL_SLOT, + network, + datum: lock.datum, + outputLovelace: lock.lockedLovelace, + mnemonic: STUB_BUYER_MNEMONIC, + fundingLovelace: lock.lockedLovelace + 10_000_000n, + }); + return { transaction: built.transaction, nonce: built.nonce }; +} + /** * Client signer stub that produces a real signed CBOR transaction offline via * {@link buildSignedTx}, so the full client→server→facilitator flow runs @@ -82,13 +177,26 @@ export function stubClientSigner(): ClientCardanoSigner { return { getAddress: () => PAYER_ADDRESS, buildAndSignPaymentTransaction: async input => { - // Honor the script method's inline datum like the real signer does, so - // full-flow script tests exercise datum attachment through the stack. + // Honor the script and masumi methods like the real signer does, so + // full-flow tests exercise datum attachment through the stack. const extra = input.extra as { assetTransferMethod?: string } | undefined; const scriptDatum = extra?.assetTransferMethod === ASSET_TRANSFER_METHOD_SCRIPT ? buildScriptDatumInline(extra as CardanoExtraScript) : undefined; + + if (extra?.assetTransferMethod === ASSET_TRANSFER_METHOD_MASUMI) { + const built = await buildStubMasumiLockTx( + extra, + input.network, + input.payTo, + input.asset, + BigInt(input.amount), + NONCE_REF, + ); + return { ...built, submissionMode: input.submissionMode, settlementLayer: "l1" }; + } + const built = await buildSignedTx({ payTo: input.payTo, asset: input.asset, @@ -98,7 +206,11 @@ export function stubClientSigner(): ClientCardanoSigner { network: input.network, ...(scriptDatum ? { datum: scriptDatum } : {}), }); - return { transaction: built.transaction, nonce: built.nonce }; + return { + transaction: built.transaction, + nonce: built.nonce, + submissionMode: input.submissionMode, + }; }, }; } diff --git a/typescript/packages/mechanisms/cardano/test/integrations/exact-cardano.test.ts b/typescript/packages/mechanisms/cardano/test/integrations/exact-cardano.test.ts index 1ed49c254b..7cdf6edd7a 100644 --- a/typescript/packages/mechanisms/cardano/test/integrations/exact-cardano.test.ts +++ b/typescript/packages/mechanisms/cardano/test/integrations/exact-cardano.test.ts @@ -12,28 +12,53 @@ import { } from "@x402/core/types"; import { ExactCardanoScheme as ExactCardanoClient } from "../../src/exact/client/scheme"; -import { ExactCardanoScheme as ExactCardanoFacilitator } from "../../src/exact/facilitator/scheme"; -import { ExactCardanoScheme as ExactCardanoServer } from "../../src/exact/server/scheme"; -import { toClientCardanoSigner, toFacilitatorCardanoSigner } from "../../src/signer"; +import { + ExactCardanoScheme as ExactCardanoFacilitatorBase, + type ExactCardanoFacilitatorConfig, +} from "../../src/exact/facilitator/scheme"; +import { + ExactCardanoScheme as ExactCardanoServerBase, + type ExactCardanoServerConfig, +} from "../../src/exact/server/scheme"; +import { + toClientCardanoSigner, + toFacilitatorCardanoSigner, + type CardanoUtxoSnapshot, + type FacilitatorCardanoSigner, +} from "../../src/signer"; import { LOVELACE_ASSET, USDM_PREPROD_ASSET } from "../../src/constants"; -import { masumiContractAddress } from "../../src/exact/masumi/constants"; -import { buildMasumiLockDatum, inlineDatum } from "../../src/exact/masumi/datum"; import { buildScriptDatumInline } from "../../src/exact/script/datum"; -import { decodeCardanoTransaction } from "../../src/utils"; -import { buildSignedTx } from "../helpers/buildSignedTx"; +import { decodeCardanoTransaction, slotToPosixMs } from "../../src/utils"; +import { buildSignedTx, getFixtureInputSnapshot } from "../helpers/buildSignedTx"; +import { issueMasumiRequirements } from "../helpers/masumi"; import { buildRequirements, + buildStubMasumiLockTx, freshPreprodAddress, MINIMAL_PLUTUS_V3, NETWORK, NONCE_REF, - PAYER_ADDRESS, scriptAddressFor, + stubBuyerAddress, stubClientSigner, stubFacilitatorSigner, TTL_SLOT, } from "../helpers/stubs"; +/** Test-only facilitator with explicit volatile replay storage. */ +class ExactCardanoFacilitator extends ExactCardanoFacilitatorBase { + constructor(signer: FacilitatorCardanoSigner, config: ExactCardanoFacilitatorConfig = {}) { + super(signer, { inMemorySettlementStoreMaxEntries: 4096, ...config }); + } +} + +/** Test-only resource server with explicit volatile replay storage. */ +class ExactCardanoServer extends ExactCardanoServerBase { + constructor(config: ExactCardanoServerConfig = {}) { + super({ inMemoryStore: {}, ...config }); + } +} + /** * Wraps the x402Facilitator for use with x402ResourceServer. */ @@ -124,12 +149,24 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const verifyResponse = await server.verifyPayment(paymentPayload, accepted!); expect(verifyResponse.isValid).toBe(true); - expect(verifyResponse.payer).toBe(PAYER_ADDRESS); + const nonceSnapshot = getFixtureInputSnapshot(NONCE_REF); + expect(nonceSnapshot).toBeDefined(); + expect(verifyResponse.payer).toBe(nonceSnapshot!.address); const settleResponse = await server.settlePayment(paymentPayload, accepted!); expect(settleResponse.success).toBe(true); expect(settleResponse.network).toBe(NETWORK); - expect(settleResponse.transaction).toBe("f".repeat(64)); + // The canonical transaction id computed over the body, not the + // submitter's echoed hash — that is what the duplicate cache keys on. + expect(settleResponse.transaction).toBe( + decodeCardanoTransaction((paymentPayload.payload as { transaction: string }).transaction) + .txHash, + ); + expect(settleResponse.extra).toMatchObject({ + status: "confirmed", + submissionMode: "server", + confirmations: 1, + }); }); it("verifies and settles a native USDM payment end to end", async () => { @@ -208,6 +245,101 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const scriptOutput = decoded.outputs.find(o => o.address === scriptAddr); expect(scriptOutput?.datum).toBe(datumHex); }); + + it("verifies and settles a masumi escrow lock end to end", async () => { + const { requirements } = await issueMasumiRequirements({ + network: NETWORK, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: BigInt(slotToPosixMs(NETWORK, TTL_SLOT)), + }); + const paymentRequired = await server.createPaymentRequiredResponse([requirements], { + url: "https://agent.example.com/weather", + description: "Agent job", + mimeType: "application/json", + }); + + const paymentPayload = await client.createPaymentPayload(paymentRequired); + const payloadFields = paymentPayload.payload as { + submissionMode: string; + settlementLayer: string; + transaction: string; + }; + expect(payloadFields.submissionMode).toBe("server"); + expect(payloadFields.settlementLayer).toBe("l1"); + + const accepted = server.findMatchingRequirements([requirements], paymentPayload); + expect(accepted).toBeDefined(); + + // The escrow output carries the exact lock value and an inline datum. + const decoded = decodeCardanoTransaction(payloadFields.transaction); + const escrowOutputs = decoded.outputs.filter(o => o.address === requirements.payTo); + expect(escrowOutputs).toHaveLength(1); + expect(escrowOutputs[0].datum).toBeDefined(); + expect(escrowOutputs[0].coin).toBe(50_000_000n); + + // The datum's buyer must control the nonce input, so the facilitator has + // to resolve that UTXO's real owner. + const buyer = await stubBuyerAddress(); + expect(buyer).toBeTruthy(); + const buyerOwned = new ExactCardanoFacilitator(stubFacilitatorSigner()); + const verifyResponse = await buyerOwned.verify(paymentPayload, accepted!); + expect(verifyResponse.isValid).toBe(true); + + const settleResponse = await buyerOwned.settle(paymentPayload, accepted!); + expect(settleResponse.success).toBe(true); + expect(settleResponse.extra).toMatchObject({ + status: "confirmed", + submissionMode: "server", + settlementLayer: "l1", + }); + }); + + it("refuses a second, different transaction for the same Masumi terms", async () => { + const { requirements } = await issueMasumiRequirements({ + network: NETWORK, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: BigInt(slotToPosixMs(NETWORK, TTL_SLOT)), + }); + const paymentRequired = await server.createPaymentRequiredResponse([requirements], { + url: "https://agent.example.com/weather", + description: "Agent job", + mimeType: "application/json", + }); + + // Two independently built locks for the same 402: both are individually + // valid and have different transaction ids, so only the termsDigest + // binding stops the duplicate deposit. + const first = await client.createPaymentPayload(paymentRequired); + const otherLock = await buildStubMasumiLockTx( + requirements.extra!, + NETWORK, + requirements.payTo, + LOVELACE_ASSET, + 50_000_000n, + `${"b".repeat(64)}#0`, + ); + const second: PaymentPayload = { + x402Version: 2, + accepted: requirements, + payload: { ...otherLock, submissionMode: "server", settlementLayer: "l1" }, + }; + const firstTx = decodeCardanoTransaction( + (first.payload as { transaction: string }).transaction, + ).txHash; + const secondTx = decodeCardanoTransaction(otherLock.transaction).txHash; + expect(firstTx).not.toBe(secondTx); + + const buyer = await stubBuyerAddress(); + expect(buyer).toBeTruthy(); + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); + expect((await facilitator.settle(first, requirements)).success).toBe(true); + + const duplicate = await facilitator.settle(second, requirements); + expect(duplicate.success).toBe(false); + expect(duplicate.errorReason).toBe("duplicate_settlement"); + }); }); describe("facilitator verify() rules against real signed transactions", () => { @@ -223,7 +355,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { payTo: string, amount: bigint, datum?: ReturnType, - ): Promise { + ): Promise<{ payload: PaymentPayload; nonceSnapshot: CardanoUtxoSnapshot }> { const built = await buildSignedTx({ payTo, asset: LOVELACE_ASSET, @@ -234,24 +366,27 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { ...(datum ? { datum } : {}), }); return { - x402Version: 2, - accepted: buildRequirements(payTo, amount.toString()), - payload: { transaction: built.transaction, nonce: built.nonce }, + payload: { + x402Version: 2, + accepted: buildRequirements(payTo, amount.toString()), + payload: { transaction: built.transaction, nonce: built.nonce }, + }, + nonceSnapshot: built.nonceSnapshot, }; } it("accepts a transaction that satisfies every rule", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); - const payload = await fixturePayload(recipient, 1_000_000n); + const { payload, nonceSnapshot } = await fixturePayload(recipient, 1_000_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "1000000")); expect(result.isValid).toBe(true); - expect(result.payer).toBe(PAYER_ADDRESS); + expect(result.payer).toBe(nonceSnapshot.address); }); it("rejects when the output pays a different recipient (rule 3)", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); const other = await freshPreprodAddress(); - const payload = await fixturePayload(recipient, 1_000_000n); + const { payload } = await fixturePayload(recipient, 1_000_000n); const result = await facilitator.verify(payload, buildRequirements(other, "1000000")); expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("invalid_exact_cardano_payload_recipient_mismatch"); @@ -259,7 +394,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { it("rejects when the output amount is insufficient (rule 4)", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); - const payload = await fixturePayload(recipient, 500_000n); + const { payload } = await fixturePayload(recipient, 500_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "1000000")); expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("invalid_exact_cardano_payload_amount_insufficient"); @@ -271,7 +406,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const facilitator = new ExactCardanoFacilitator( stubFacilitatorSigner({ getCoinsPerUtxoByte: async () => 100_000n }), ); - const payload = await fixturePayload(recipient, 1_000_000n); + const { payload } = await fixturePayload(recipient, 1_000_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "1000000")); expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("invalid_exact_cardano_payload_min_utxo_insufficient"); @@ -281,7 +416,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const facilitator = new ExactCardanoFacilitator( stubFacilitatorSigner({ getCoinsPerUtxoByte: async () => 4310n }), ); - const payload = await fixturePayload(recipient, 2_000_000n); + const { payload } = await fixturePayload(recipient, 2_000_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "2000000")); expect(result.isValid).toBe(true); }); @@ -290,7 +425,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const facilitator = new ExactCardanoFacilitator( stubFacilitatorSigner({ getUtxo: async () => ({ exists: false }) }), ); - const payload = await fixturePayload(recipient, 1_000_000n); + const { payload } = await fixturePayload(recipient, 1_000_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "1000000")); expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("invalid_exact_cardano_payload_nonce_not_on_chain"); @@ -300,7 +435,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { const facilitator = new ExactCardanoFacilitator( stubFacilitatorSigner({ getCurrentSlot: async () => TTL_SLOT + 1n }), ); - const payload = await fixturePayload(recipient, 1_000_000n); + const { payload } = await fixturePayload(recipient, 1_000_000n); const result = await facilitator.verify(payload, buildRequirements(recipient, "1000000")); expect(result.isValid).toBe(false); expect(result.invalidReason).toBe("invalid_exact_cardano_payload_ttl_expired"); @@ -309,7 +444,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { it("accepts a script payment to the reconstructed script address", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); const { address: scriptAddr } = scriptAddressFor(MINIMAL_PLUTUS_V3); - const payload = await fixturePayload(scriptAddr, 2_000_000n); + const { payload } = await fixturePayload(scriptAddr, 2_000_000n); const requirements = buildRequirements(scriptAddr, "2000000", LOVELACE_ASSET, { assetTransferMethod: "script", script: { type: "plutusV3", code: MINIMAL_PLUTUS_V3 }, @@ -328,7 +463,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { script: { type: "plutusV3", code: MINIMAL_PLUTUS_V3 }, datum: datumHex, }); - const payload = await fixturePayload(scriptAddr, 2_000_000n, datum); + const { payload } = await fixturePayload(scriptAddr, 2_000_000n, datum); const requirements = buildRequirements(scriptAddr, "2000000", LOVELACE_ASSET, { assetTransferMethod: "script", script: { type: "plutusV3", code: MINIMAL_PLUTUS_V3 }, @@ -346,7 +481,7 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { it("rejects a script payment whose payTo is not the declared script", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); - const payload = await fixturePayload(recipient, 2_000_000n); + const { payload } = await fixturePayload(recipient, 2_000_000n); const requirements = buildRequirements(recipient, "2000000", LOVELACE_ASSET, { assetTransferMethod: "script", script: { type: "plutusV3", code: MINIMAL_PLUTUS_V3 }, @@ -356,100 +491,21 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { expect(result.invalidReason).toBe("invalid_exact_cardano_payload_script_address_mismatch"); }); - it("accepts a masumi lock into the escrow with a valid FundsLocked datum", async () => { - // The datum buyer must equal the payer the facilitator resolves from the - // nonce input, so return a real (parseable) buyer address from getUtxo. - const buyer = await freshPreprodAddress(); - const facilitator = new ExactCardanoFacilitator( - stubFacilitatorSigner({ getUtxo: async () => ({ exists: true, address: buyer }) }), - ); - const escrow = masumiContractAddress(NETWORK); - const datum = inlineDatum( - buildMasumiLockDatum({ - buyerAddress: buyer, - sellerAddress: recipient, - referenceKey: "aa".repeat(32), - referenceSignature: "bb".repeat(32), - sellerNonce: "cc".repeat(32), - buyerNonce: "dd".repeat(32), - agentIdentifier: "ee".repeat(16), - collateralReturnLovelace: 0n, - inputHash: "", - // pay_by_time must be on/after the tx's TTL slot wall-time, so the lock - // cannot settle past the deadline (facilitator deadline check). - payByTime: 1_900_000_000_000n, - submitResultTime: 1_900_000_100_000n, - unlockTime: 1_900_000_200_000n, - externalDisputeUnlockTime: 1_900_000_300_000n, - }), - ); - const payload = await fixturePayload(escrow, 5_000_000n, datum); - const requirements = buildRequirements(escrow, "5000000", LOVELACE_ASSET, { - assetTransferMethod: "masumi", - contractAddress: escrow, - sellerAddress: recipient, - }); - const result = await facilitator.verify(payload, requirements); - expect(result.isValid).toBe(true); - }); - - it("accepts a masumi USDM lock into the escrow with a valid FundsLocked datum", async () => { - const buyer = await freshPreprodAddress(); - const facilitator = new ExactCardanoFacilitator( - stubFacilitatorSigner({ getUtxo: async () => ({ exists: true, address: buyer }) }), - ); - const escrow = masumiContractAddress(NETWORK); - const datum = inlineDatum( - buildMasumiLockDatum({ - buyerAddress: buyer, - sellerAddress: recipient, - referenceKey: "aa".repeat(32), - referenceSignature: "bb".repeat(32), - sellerNonce: "cc".repeat(32), - buyerNonce: "dd".repeat(32), - agentIdentifier: "ee".repeat(16), - collateralReturnLovelace: 0n, - inputHash: "", - payByTime: 1_900_000_000_000n, - submitResultTime: 1_900_000_100_000n, - unlockTime: 1_900_000_200_000n, - externalDisputeUnlockTime: 1_900_000_300_000n, - }), - ); - // The escrow output carries the token exactly; its lovelace is structural. - const built = await buildSignedTx({ - payTo: escrow, - asset: USDM_PREPROD_ASSET, - amount: 1_500_000n, - nonceUtxoRef: NONCE_REF, - ttlSlot: TTL_SLOT, + it("rejects a masumi payment whose payTo is not the derived escrow address", async () => { + const { requirements } = await issueMasumiRequirements({ network: NETWORK, - datum, - }); - const payload: PaymentPayload = { - x402Version: 2, - accepted: buildRequirements(escrow, "1500000", USDM_PREPROD_ASSET), - payload: { transaction: built.transaction, nonce: built.nonce }, - }; - const requirements = buildRequirements(escrow, "1500000", USDM_PREPROD_ASSET, { - assetTransferMethod: "masumi", - contractAddress: escrow, - sellerAddress: recipient, + asset: LOVELACE_ASSET, + amount: "5000000", + payByTimeMs: BigInt(slotToPosixMs(NETWORK, TTL_SLOT)), }); - const result = await facilitator.verify(payload, requirements); - expect(result.isValid).toBe(true); - }); - - it("rejects a masumi payment whose payTo is not the declared escrow address", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); - const payload = await fixturePayload(recipient, 5_000_000n); - const requirements = buildRequirements(recipient, "5000000", LOVELACE_ASSET, { - assetTransferMethod: "masumi", - contractAddress: masumiContractAddress(NETWORK), - sellerAddress: recipient, + const { payload } = await fixturePayload(recipient, 5_000_000n); + const result = await facilitator.verify(payload, { + ...requirements, + payTo: recipient, }); - const result = await facilitator.verify(payload, requirements); expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_cardano_requirements_masumi_deployment"); }); it("accepts a multi-input tx when every input is unspent, rejects when one is spent", async () => { @@ -483,15 +539,92 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { expect(ok.isValid).toBe(true); // The coin-selected (non-nonce) input is already spent → rejected. + const unspentSigner = stubFacilitatorSigner(); const spent = await new ExactCardanoFacilitator( stubFacilitatorSigner({ - getUtxo: async ref => ({ exists: !ref.startsWith("bbbb"), address: PAYER_ADDRESS }), + getUtxo: async (ref, network) => ({ + ...(await unspentSigner.getUtxo(ref, network)), + exists: !ref.startsWith("bbbb"), + }), }), ).verify(payload, requirements); expect(spent.isValid).toBe(false); expect(spent.invalidReason).toBe("invalid_exact_cardano_payload_input_not_available"); }); + // A phase-2-invalid ("failed script") transaction lands under its own id but + // consumes its collateral instead of its inputs and creates NONE of its + // declared outputs. Decoding it shows a perfectly good payment output that + // the ledger never produced, so accepting it hands over the resource for + // free — the more so in client mode, where the client picks what to submit. + it("rejects a phase-2-invalid transaction even though it decodes as paying", async () => { + const built = await buildSignedTx({ + payTo: recipient, + asset: LOVELACE_ASSET, + amount: 2_000_000n, + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: NETWORK, + }); + const original = Transaction.fromCBORBytes( + Uint8Array.from(Buffer.from(built.transaction, "base64")), + ); + const failed = new Transaction.Transaction({ + body: original.body, + witnessSet: original.witnessSet, + isValid: false, + auxiliaryData: null, + }); + const transaction = Buffer.from(Transaction.toCBORBytes(failed)).toString("base64"); + + // It still decodes as a valid-looking payment to the right address. + const decoded = decodeCardanoTransaction(transaction); + expect(decoded.outputs.some(o => o.address === recipient && o.coin >= 2_000_000n)).toBe(true); + expect(decoded.isValid).toBe(false); + + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner()); + const result = await facilitator.verify( + { + x402Version: 2, + accepted: buildRequirements(recipient, "2000000"), + payload: { transaction, nonce: NONCE_REF }, + }, + buildRequirements(recipient, "2000000"), + ); + expect(result.isValid).toBe(false); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_phase2_invalid"); + }); + + it("accepts a client-submitted payment carrying no Plutus redeemers", async () => { + const built = await buildSignedTx({ + payTo: recipient, + asset: LOVELACE_ASSET, + amount: 2_000_000n, + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: NETWORK, + }); + expect(decodeCardanoTransaction(built.transaction).redeemerCount).toBe(0); + + const clientReqs = buildRequirements(recipient, "2000000", LOVELACE_ASSET, { + submissionPolicy: "client", + }); + const facilitator = new ExactCardanoFacilitator( + stubFacilitatorSigner({ + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 1 }), + }), + ); + const result = await facilitator.verify( + { + x402Version: 2, + accepted: clientReqs, + payload: { transaction: built.transaction, nonce: NONCE_REF, submissionMode: "client" }, + }, + clientReqs, + ); + expect(result.isValid).toBe(true); + }); + it("rejects a transaction whose vkey signature does not match the body", async () => { // Graft a second transaction's witness onto the first's body: the grafted // signature was produced over a different body hash, so it is invalid. @@ -605,34 +738,43 @@ describe("Cardano Integration Tests (deterministic, offline)", () => { }); }); -// Live preprod settlement. Skipped unless funded test wallets + a Blockfrost -// project id are provided. Mirrors the env-gated style of the other mechanisms -// but skips cleanly so CI without secrets stays green. +// Live preprod settlement. Skipped unless a funded client wallet, a Blockfrost +// project id and a payee address are provided. Mirrors the env-gated style of +// the other mechanisms but skips cleanly so CI without secrets stays green. +// +// The facilitator needs no funds — it only broadcasts the client's already +// signed transaction — so FACILITATOR_CARDANO_MNEMONIC is optional. const LIVE_ENV = { clientMnemonic: process.env.CLIENT_CARDANO_MNEMONIC, - facilitatorMnemonic: process.env.FACILITATOR_CARDANO_MNEMONIC, blockfrostBaseUrl: process.env.BLOCKFROST_PREPROD_URL, blockfrostProjectId: process.env.BLOCKFROST_PROJECT_ID, - payTo: process.env.RESOURCE_SERVER_CARDANO_ADDRESS, + payTo: process.env.RESOURCE_SERVER_CARDANO_ADDRESS || process.env.SERVER_CARDANO_ADDRESS, }; const LIVE_READY = Object.values(LIVE_ENV).every(Boolean); +/** Optional; only exposes an address in `/supported`. */ +const LIVE_FACILITATOR_MNEMONIC = process.env.FACILITATOR_CARDANO_MNEMONIC; describe.skipIf(!LIVE_READY)("Cardano Integration Tests (live preprod)", () => { - it("verifies and settles a real payment on preprod", async () => { - const provider = { - blockfrost: { - baseUrl: LIVE_ENV.blockfrostBaseUrl!, - projectId: LIVE_ENV.blockfrostProjectId!, - }, - }; + const provider = { + blockfrost: { + baseUrl: LIVE_ENV.blockfrostBaseUrl!, + projectId: LIVE_ENV.blockfrostProjectId!, + }, + }; + /** + * Wires a live client / facilitator / resource-server triple against preprod. + * + * @returns The client and the resource server. + */ + async function liveStack(): Promise<{ client: x402Client; server: x402ResourceServer }> { const clientSigner = toClientCardanoSigner({ mnemonic: LIVE_ENV.clientMnemonic!, network: NETWORK, provider, }); const facilitatorSigner = toFacilitatorCardanoSigner({ - mnemonic: LIVE_ENV.facilitatorMnemonic!, + ...(LIVE_FACILITATOR_MNEMONIC ? { mnemonic: LIVE_FACILITATOR_MNEMONIC } : {}), network: NETWORK, provider, awaitConfirmation: true, @@ -646,8 +788,60 @@ describe.skipIf(!LIVE_READY)("Cardano Integration Tests (live preprod)", () => { const server = new x402ResourceServer(new CardanoFacilitatorClient(facilitator)); server.register(NETWORK, new ExactCardanoServer()); await server.initialize(); + return { client, server }; + } + + /** The nonce UTXO the previous live payment consumed. */ + let lastSpentNonce: string | undefined; + + /** + * Waits until the provider's address index no longer offers the UTXO the + * previous payment consumed. `awaitTx` only proves block inclusion; the + * address-UTXO index can still lag, and the reference signer always takes the + * first wallet UTXO as its nonce — so without this a back-to-back payment + * rebuilds on a spent input and the node rejects it. + * + * @param address - The funding wallet's bech32 address. + * @returns Nothing. + */ + async function waitForFreshUtxo(address: string): Promise { + if (!lastSpentNonce) return; + const deadline = Date.now() + 120_000; + for (;;) { + const response = await fetch(`${provider.blockfrost.baseUrl}/addresses/${address}/utxos`, { + headers: { project_id: provider.blockfrost.projectId }, + }); + if (response.ok) { + const utxos = (await response.json()) as Array<{ + tx_hash: string; + output_index: number; + }>; + const fresh = utxos.some(u => `${u.tx_hash}#${u.output_index}` !== lastSpentNonce); + if (fresh) return; + } + if (Date.now() > deadline) { + throw new Error(`wallet ${address} still only offers the spent ${lastSpentNonce}`); + } + await new Promise(resolve => setTimeout(resolve, 5_000)); + } + } - const accepts = [buildRequirements(LIVE_ENV.payTo!, "1000000")]; + /** + * Runs one payment all the way to settlement against preprod. + * + * @param requirements - The requirements to pay. + * @returns The settle response. + */ + async function payLive(requirements: PaymentRequirements): Promise { + const { client, server } = await liveStack(); + await waitForFreshUtxo( + toClientCardanoSigner({ + mnemonic: LIVE_ENV.clientMnemonic!, + network: NETWORK, + provider, + }).getAddress(), + ); + const accepts = [requirements]; const paymentRequired = await server.createPaymentRequiredResponse(accepts, { url: "https://company.co", description: "Company Co. resource", @@ -659,10 +853,71 @@ describe.skipIf(!LIVE_READY)("Cardano Integration Tests (live preprod)", () => { expect(accepted).toBeDefined(); const verifyResponse = await server.verifyPayment(paymentPayload, accepted!); - expect(verifyResponse.isValid).toBe(true); + expect(verifyResponse.isValid, `verify failed: ${verifyResponse.invalidReason}`).toBe(true); - const settleResponse = await server.settlePayment(paymentPayload, accepted!); - expect(settleResponse.success).toBe(true); - expect(settleResponse.transaction.length).toBeGreaterThan(0); - }); + lastSpentNonce = (paymentPayload.payload as { nonce: string }).nonce; + return server.settlePayment(paymentPayload, accepted!); + } + + // Canonical block inclusion rather than the default one confirmation: a + // preprod block is ~20s, and the reference facilitator signer already awaits + // inclusion before reporting. The threshold itself is covered offline. + const LIVE_CONFIRMATION_POLICY = { l1Confirmations: 0 }; + + it("verifies and settles an address-to-address payment", async () => { + const settleResponse = await payLive( + buildRequirements(LIVE_ENV.payTo!, "1000000", LOVELACE_ASSET, { + confirmationPolicy: LIVE_CONFIRMATION_POLICY, + }), + ); + expect( + settleResponse.success, + `${settleResponse.errorReason}: ${settleResponse.errorMessage ?? ""}`, + ).toBe(true); + expect(settleResponse.transaction).toMatch(/^[0-9a-f]{64}$/); + expect(settleResponse.extra).toMatchObject({ status: "confirmed", submissionMode: "server" }); + }, 300_000); + + it("verifies and settles a script lock carrying an inline datum", async () => { + const { address: scriptAddr } = scriptAddressFor(MINIMAL_PLUTUS_V3); + const settleResponse = await payLive( + buildRequirements(scriptAddr, "2000000", LOVELACE_ASSET, { + assetTransferMethod: "script", + script: { type: "plutusV3", code: MINIMAL_PLUTUS_V3 }, + datum: Data.toCBORHex(Data.constr(0n, [Data.int(42n)])), + confirmationPolicy: LIVE_CONFIRMATION_POLICY, + }), + ); + expect( + settleResponse.success, + `${settleResponse.errorReason}: ${settleResponse.errorMessage ?? ""}`, + ).toBe(true); + expect(settleResponse.transaction).toMatch(/^[0-9a-f]{64}$/); + }, 300_000); + + it("verifies and settles a Masumi escrow lock", async () => { + // A conformant issuer signs the terms; pay_by_time stays inside + // maxTimeoutSeconds so the anchored TTL clears rule 7's upper bound. + const maxTimeoutSeconds = 600; + const { requirements } = await issueMasumiRequirements({ + network: NETWORK, + asset: LOVELACE_ASSET, + amount: "5000000", + maxTimeoutSeconds, + payByTimeMs: BigInt(Date.now() + maxTimeoutSeconds * 1000), + confirmationPolicy: LIVE_CONFIRMATION_POLICY, + }); + + const settleResponse = await payLive(requirements); + expect( + settleResponse.success, + `${settleResponse.errorReason}: ${settleResponse.errorMessage ?? ""}`, + ).toBe(true); + expect(settleResponse.transaction).toMatch(/^[0-9a-f]{64}$/); + expect(settleResponse.extra).toMatchObject({ + status: "confirmed", + submissionMode: "server", + settlementLayer: "l1", + }); + }, 300_000); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/idempotency.test.ts b/typescript/packages/mechanisms/cardano/test/unit/idempotency.test.ts new file mode 100644 index 0000000000..679a038375 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/idempotency.test.ts @@ -0,0 +1,326 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + InMemoryCardanoOperationStore, + InMemoryCardanoSettlementStore, + type CardanoOperationClaim, + type CardanoStoredResponse, +} from "../../src/idempotency"; + +const operationClaim = (overrides: Partial = {}): CardanoOperationClaim => ({ + key: "transaction:abc", + txHash: "abc", + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + requireReplayChallenge: false, + ownerToken: "owner-a", + ...overrides, +}); + +const response = (body: unknown = { ok: true }): CardanoStoredResponse => ({ + status: 200, + contentType: "application/json", + headers: {}, + body, + isRaw: false, +}); + +describe("Cardano idempotency stores", () => { + it("claims atomically and binds a payment to one request fingerprint", async () => { + const store = new InMemoryCardanoOperationStore(); + const [first, second] = await Promise.all([ + store.claim(operationClaim()), + store.claim(operationClaim({ ownerToken: "owner-b" })), + ]); + + expect([first.status, second.status].sort()).toEqual(["claimed", "in-progress"]); + expect( + await store.claim(operationClaim({ fingerprint: "request-b", ownerToken: "owner-c" })), + ).toEqual({ status: "request-conflict" }); + }); + + it("does not let another request complete or release a live claim", async () => { + const store = new InMemoryCardanoOperationStore(); + await store.claim(operationClaim()); + + expect(await store.complete("transaction:abc", "owner-b", response(), 10)).toBe("not-owner"); + await store.release("transaction:abc", "owner-b"); + expect(await store.claim(operationClaim({ ownerToken: "owner-c" }))).toEqual({ + status: "in-progress", + }); + }); + + it("fails closed when entry or response-byte limits are reached", async () => { + const store = new InMemoryCardanoOperationStore({ + maxEntries: 1, + maxResponseBytes: 4, + maxTotalResponseBytes: 4, + }); + await store.claim(operationClaim()); + + expect(await store.complete("transaction:abc", "owner-a", response("large"), 5)).toBe( + "response-too-large", + ); + expect(await store.claim(operationClaim({ ownerToken: "owner-b" }))).toEqual({ + status: "completed-without-response", + }); + expect( + await store.claim( + operationClaim({ + key: "transaction:def", + txHash: "def", + ownerToken: "owner-def", + }), + ), + ).toEqual({ status: "capacity-exceeded" }); + }); + + it("retains completed and ambiguous consumption tombstones", async () => { + const store = new InMemoryCardanoOperationStore(); + await store.claim(operationClaim()); + await store.complete("transaction:abc", "owner-a", response(), 10); + expect(await store.claim(operationClaim({ ownerToken: "owner-b" }))).toMatchObject({ + status: "completed", + }); + + await store.claim( + operationClaim({ key: "transaction:def", txHash: "def", ownerToken: "owner-def" }), + ); + expect(await store.markAmbiguous("transaction:def", "owner-def")).toBe("stored"); + await store.release("transaction:def", "owner-def"); + expect( + await store.claim( + operationClaim({ key: "transaction:def", txHash: "def", ownerToken: "owner-retry" }), + ), + ).toEqual({ status: "ambiguous" }); + }); + + it("shares owner-safe settlement claims across facilitator instances", async () => { + const store = new InMemoryCardanoSettlementStore(2); + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "server", + ownerToken: "owner-a", + }), + ).toBe("fresh"); + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "server", + ownerToken: "owner-b", + }), + ).toBe("in-flight"); + + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "client", + ownerToken: "owner-c", + }), + ).toBe("mode-conflict"); + }); + + it("retains definitive submission rejections as terminal tombstones", async () => { + const store = new InMemoryCardanoSettlementStore(); + await store.claimSettlement({ txHash: "abc", mode: "server", ownerToken: "owner-a" }); + await store.markRejected("abc", "owner-a"); + + expect( + await store.claimSettlement({ txHash: "abc", mode: "server", ownerToken: "owner-b" }), + ).toBe("rejected"); + }); + + it("keeps Masumi terms permanently bound to their first transaction", async () => { + const store = new InMemoryCardanoSettlementStore(); + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "server", + ownerToken: "owner-a", + termsDigest: "terms", + }), + ).toBe("fresh"); + expect( + await store.claimSettlement({ + txHash: "def", + mode: "server", + ownerToken: "owner-b", + termsDigest: "terms", + }), + ).toBe("terms-conflict"); + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "server", + ownerToken: "owner-c", + termsDigest: "terms", + }), + ).toBe("in-flight"); + }); + + it("claims Masumi terms and transaction atomically", async () => { + const store = new InMemoryCardanoSettlementStore(1); + expect( + await store.claimSettlement({ + txHash: "abc", + mode: "server", + ownerToken: "owner-a", + termsDigest: "terms", + }), + ).toBe("capacity-exceeded"); + expect( + await store.claimSettlement({ + txHash: "def", + mode: "server", + ownerToken: "owner-b", + }), + ).toBe("fresh"); + }); + + it("binds replay challenges to one request and requirement", async () => { + const store = new InMemoryCardanoOperationStore(); + const issued = await store.issueChallenge({ + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + expiresAt: Date.now() + 60_000, + }); + if (issued.status !== "issued") throw new Error("test challenge was not issued"); + + expect( + await store.validateChallenge(issued.challenge, { + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + }), + ).toBe(true); + expect( + await store.validateChallenge(issued.challenge, { + fingerprint: "request-b", + requirementsFingerprint: "requirements-a", + }), + ).toBe(false); + + expect( + await store.claim( + operationClaim({ replayChallenge: issued.challenge, requireReplayChallenge: true }), + ), + ).toEqual({ status: "claimed" }); + expect( + await store.claim( + operationClaim({ + ownerToken: "owner-b", + replayChallenge: "b".repeat(64), + requireReplayChallenge: true, + }), + ), + ).toEqual({ status: "challenge-invalid" }); + }); + + it("rejects missing, expired, or misbound replay challenges", async () => { + const store = new InMemoryCardanoOperationStore(); + const issued = await store.issueChallenge({ + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + expiresAt: Date.now() + 60_000, + }); + if (issued.status !== "issued") throw new Error("test challenge was not issued"); + + expect(await store.claim(operationClaim({ requireReplayChallenge: true }))).toEqual({ + status: "challenge-invalid", + }); + expect( + await store.claim( + operationClaim({ + key: "transaction:def", + txHash: "def", + fingerprint: "request-b", + replayChallenge: issued.challenge, + requireReplayChallenge: true, + }), + ), + ).toEqual({ status: "challenge-invalid" }); + }); + + it("rejects an unused challenge after its issuance lifetime", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-08-04T12:00:00Z")); + const store = new InMemoryCardanoOperationStore(); + const issued = await store.issueChallenge({ + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + expiresAt: Date.now() + 1_000, + }); + if (issued.status !== "issued") throw new Error("test challenge was not issued"); + vi.advanceTimersByTime(1_001); + + expect( + await store.validateChallenge(issued.challenge, { + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + }), + ).toBe(false); + expect( + await store.claim( + operationClaim({ replayChallenge: issued.challenge, requireReplayChallenge: true }), + ), + ).toEqual({ status: "challenge-invalid" }); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps a consumed challenge valid for idempotent retries", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-08-04T12:00:00Z")); + const store = new InMemoryCardanoOperationStore(); + const issued = await store.issueChallenge({ + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + expiresAt: Date.now() + 1_000, + }); + if (issued.status !== "issued") throw new Error("test challenge was not issued"); + await store.claim( + operationClaim({ replayChallenge: issued.challenge, requireReplayChallenge: true }), + ); + vi.advanceTimersByTime(1_001); + + expect( + await store.validateChallenge(issued.challenge, { + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + }), + ).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + + it("allows one challenge to claim only one canonical operation", async () => { + const store = new InMemoryCardanoOperationStore(); + const issued = await store.issueChallenge({ + fingerprint: "request-a", + requirementsFingerprint: "requirements-a", + expiresAt: Date.now() + 60_000, + }); + if (issued.status !== "issued") throw new Error("test challenge was not issued"); + + expect( + await store.claim( + operationClaim({ replayChallenge: issued.challenge, requireReplayChallenge: true }), + ), + ).toEqual({ status: "claimed" }); + expect( + await store.claim( + operationClaim({ + key: "transaction:def", + txHash: "def", + ownerToken: "owner-b", + replayChallenge: issued.challenge, + requireReplayChallenge: true, + }), + ), + ).toEqual({ status: "challenge-invalid" }); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/masumiCodec.test.ts b/typescript/packages/mechanisms/cardano/test/unit/masumiCodec.test.ts new file mode 100644 index 0000000000..92f7b024bc --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/masumiCodec.test.ts @@ -0,0 +1,294 @@ +import { Address, COSE } from "@evolution-sdk/evolution"; +import LZString from "lz-string"; +import { describe, expect, it } from "vitest"; + +import { + MASUMI_BLUEPRINT_DIGEST, + MASUMI_DEFAULT_DEPLOYMENT, + masumiEscrowAddress, + masumiEscrowScriptHash, +} from "../../src/exact/masumi/blueprint"; +import { verifySellerTermsSignature } from "../../src/exact/masumi/cose"; +import { commitmentPartDigest, computeInputHash } from "../../src/exact/masumi/digests"; +import { + buildIdentifierText, + decodeBlockchainIdentifier, + encodeBlockchainIdentifier, +} from "../../src/exact/masumi/identifier"; +import { jcs } from "../../src/exact/masumi/jcs"; +import { CARDANO_MAINNET_CAIP2, CARDANO_PREPROD_CAIP2 } from "../../src/constants"; +import { MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES } from "../../src/limits"; +import { freshKeyAddress } from "../helpers/masumi"; + +/** The escrow address both spec identifier vectors are built against. */ +const SPEC_ESCROW = "addr_test1wzs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgn37w4g"; + +describe("RFC 8785 JCS", () => { + it("sorts object members by UTF-16 code units", () => { + expect(jcs({ b: 1, a: 2, A: 3 })).toBe('{"A":3,"a":2,"b":1}'); + }); + + it("is insensitive to insertion order", () => { + expect(jcs({ z: [1, { y: 2, x: 3 }], a: null })).toBe(jcs({ a: null, z: [1, { x: 3, y: 2 }] })); + }); + + it("omits members whose value is undefined, but keeps null", () => { + expect(jcs({ a: undefined, b: null })).toBe('{"b":null}'); + }); + + it("preserves array order and serializes numbers in ECMAScript form", () => { + expect(jcs([3, 1, 2])).toBe("[3,1,2]"); + expect(jcs({ n: 1e21, m: -0.5 })).toBe('{"m":-0.5,"n":1e+21}'); + }); + + it("refuses values JSON cannot represent", () => { + expect(() => jcs({ n: Number.NaN })).toThrow(/non-finite/); + expect(() => jcs({ n: 1n })).toThrow(/bigint/); + }); + + // RFC 8785 requires canonicalization to fail on invalid Unicode. JSON.stringify + // would instead escape it, producing a digest a conforming peer refuses to + // compute — a silent disagreement on inputHash and termsDigest. + it("refuses unpaired surrogates in values and member names", () => { + expect(() => jcs({ a: "\ud800" })).toThrow(/unpaired surrogate/); + expect(() => jcs({ a: "\udc00x" })).toThrow(/unpaired surrogate/); + expect(() => jcs({ "\ud800": 1 })).toThrow(/unpaired surrogate/); + expect(() => jcs(["ok", "\ud83d"])).toThrow(/unpaired surrogate/); + }); + + it("accepts a correctly paired surrogate", () => { + expect(jcs({ a: "😀" })).toBe('{"a":"😀"}'); + }); +}); + +// The spec fixes these two encoding-only vectors for the compatibility codec. +// The short key and signature values are deliberately not valid COSE objects. +describe("blockchainIdentifier codec (spec vectors)", () => { + const vectors = [ + { + name: "unregistered seller with an empty buyer nonce", + parts: { + sellerNonce: "11".repeat(32), + agentIdentifier: "", + buyerNonce: "", + referenceSignature: "55".repeat(16), + referenceKey: "a10101", + contractAddress: SPEC_ESCROW, + }, + text: `${"11".repeat(32)}..${"55".repeat(16)}.a10101.${SPEC_ESCROW}`, + hex: "230d7c6574f41d1c0acc96ade8eae04360019f607004d8809c07d005c053019cae007700bce8058680d89818c04e44002c035931a2c00daf5e00ac9bf00b6c401b80473c6535d00e6003cb8b110199db615001ca8eecc6019b58076c603b13763a80", + }, + { + name: "registered seller", + parts: { + sellerNonce: "22".repeat(32), + agentIdentifier: `${"aa".repeat(28)}01`, + buyerNonce: "01020304050607", + referenceSignature: "66".repeat(16), + referenceKey: "a10102", + contractAddress: SPEC_ESCROW, + }, + text: `${"22".repeat(32)}${"aa".repeat(28)}01.01020304050607.${"66".repeat(16)}.a10102.${SPEC_ESCROW}`, + hex: "130d7c6574f4218314e4b56f46e00602300e972d82c0662c0162c0562c0362c0763d6975b7d8f3b6f3874381e004d0402700fa005c0298067093803b802f19e4a6d05018c02715001601ac154a5006d36680560bb405b4100dc0239611ae64073001eb494192e4700e000e121e70240066610076240c0ae41e400000", + }, + ]; + + for (const vector of vectors) { + it(`builds the exact identifierText for the ${vector.name}`, () => { + expect(buildIdentifierText(vector.parts)).toBe(vector.text); + }); + + it(`encodes the exact blockchainIdentifier for the ${vector.name}`, () => { + expect(encodeBlockchainIdentifier(vector.parts)).toBe(vector.hex); + }); + + it(`decompresses the ${vector.name} back to every segment`, () => { + expect(decodeBlockchainIdentifier(vector.hex)).toEqual(vector.parts); + }); + } + + it("preserves an empty buyer-nonce segment", () => { + expect(decodeBlockchainIdentifier(vectors[0].hex)?.buyerNonce).toBe(""); + }); + + it("rejects malformed input", () => { + expect(decodeBlockchainIdentifier("")).toBeNull(); + expect(decodeBlockchainIdentifier("zz")).toBeNull(); + expect(decodeBlockchainIdentifier("abc")).toBeNull(); + }); +}); + +describe("blockchainIdentifier resource limits", () => { + it("rejects compressed identifiers above the implementation budget", () => { + expect( + decodeBlockchainIdentifier("00".repeat(MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES + 1)), + ).toBeNull(); + }); + + it("aborts a compressed identifier whose expanded text exceeds the limit", () => { + const compressed = LZString.compressToUint8Array("a".repeat(100_000)); + expect(compressed.length).toBeLessThan(MAX_MASUMI_IDENTIFIER_COMPRESSED_BYTES); + expect(decodeBlockchainIdentifier(Buffer.from(compressed).toString("hex"))).toBeNull(); + }); +}); + +describe("canonical vested_pay deployment", () => { + it("pins the blueprint digest the spec names", () => { + expect(MASUMI_BLUEPRINT_DIGEST).toBe( + "6249de17bb87c5246106af6b0f33de22b44ca24b9c1445fa36d10eb8b583dec7", + ); + }); + + // The un-applied blueprint hash is NOT an escrow address; the parameters are + // baked into the script hash. The applied preprod address is the one the + // spec's identifier vectors are built against. + it("derives the spec's escrow address by applying the default parameters", () => { + expect(masumiEscrowAddress(CARDANO_PREPROD_CAIP2)).toBe(SPEC_ESCROW); + expect(masumiEscrowScriptHash(MASUMI_DEFAULT_DEPLOYMENT)).toBe( + "a15ce9d82d2f67645fc624e2edac03c6f1c106d0ad1af5815a3b14ad", + ); + }); + + it("keeps the same script hash across networks, changing only the header", () => { + expect(masumiEscrowAddress(CARDANO_MAINNET_CAIP2)).toBe( + "addr1wxs4e6wc95hkwezlccjw9mdvq0r0rsgx6zk34avptga3ftgge2j6d", + ); + }); + + it("yields a different address for a different parameterization", () => { + const custom = masumiEscrowScriptHash({ + ...MASUMI_DEFAULT_DEPLOYMENT, + requiredAdmins: "3", + }); + expect(custom).not.toBe(masumiEscrowScriptHash(MASUMI_DEFAULT_DEPLOYMENT)); + }); + + it("treats a duplicated admin key as a distinct deployment", () => { + const weighted = masumiEscrowScriptHash({ + requiredAdmins: "2", + adminVkeys: [ + MASUMI_DEFAULT_DEPLOYMENT.adminVkeys[0], + MASUMI_DEFAULT_DEPLOYMENT.adminVkeys[0], + MASUMI_DEFAULT_DEPLOYMENT.adminVkeys[1], + ], + cooldownPeriod: "420000", + }); + expect(weighted).not.toBe(masumiEscrowScriptHash(MASUMI_DEFAULT_DEPLOYMENT)); + }); +}); + +describe("input commitment", () => { + it("derives inputHash from the content-free manifest", () => { + const part = { + name: "body", + canonicalization: "jcs" as const, + mediaType: "application/json", + content: { days: 3, units: "metric" }, + }; + const commitment = { + version: "1", + algorithm: "sha256", + parts: [{ ...part, digest: commitmentPartDigest(part) }], + digest: "", + }; + const digest = computeInputHash(commitment); + expect(digest).toMatch(/^[0-9a-f]{64}$/); + + // Omitting `content` on the wire must not change inputHash: the manifest + // excludes it by construction. + const withoutContent = { + ...commitment, + parts: commitment.parts.map(({ content: _content, ...rest }) => rest), + }; + expect(computeInputHash(withoutContent)).toBe(digest); + + // A changed part digest DOES change inputHash. + expect( + computeInputHash({ + ...commitment, + parts: commitment.parts.map(p => ({ ...p, digest: "0".repeat(64) })), + }), + ).not.toBe(digest); + }); + + it("digests a raw part from its base64url bytes", () => { + // "hi" -> base64url "aGk"; SHA-256("hi") is the well-known value below. + expect(commitmentPartDigest({ canonicalization: "raw", content: "aGk" })).toBe( + "8f434346648f6b96df89dda901c5176b10a6d83961dd3c1ac88b59b2dc327aa4", + ); + }); + + it("refuses base64url content that is not base64url", () => { + expect(() => commitmentPartDigest({ canonicalization: "raw", content: "a+b/c=" })).toThrow( + /base64url/, + ); + }); + + it("refuses non-canonical base64url encodings", () => { + expect(() => commitmentPartDigest({ canonicalization: "raw", content: "a" })).toThrow( + /canonical/, + ); + expect(() => commitmentPartDigest({ canonicalization: "raw", content: "Zh" })).toThrow( + /canonical/, + ); + }); +}); + +describe("seller COSE authorization", () => { + const digest = "ab".repeat(32); + + /** + * Signs a digest with a fresh key, returning the wire hex pair. + * + * @param payloadHex - The payload to sign. + * @returns The seller address plus the COSE key/signature hex. + */ + const sign = (payloadHex: string) => { + const { privateKey, address } = freshKeyAddress(CARDANO_PREPROD_CAIP2); + const signed = COSE.SignData.signData( + Address.toHex(Address.fromBech32(address)), + Uint8Array.from(Buffer.from(payloadHex, "hex")), + privateKey, + ); + return { + address, + key: Buffer.from(signed.key).toString("hex"), + signature: Buffer.from(signed.signature).toString("hex"), + }; + }; + + it("accepts a CIP-30 signData result over the terms digest", () => { + const { address, key, signature } = sign(digest); + expect(verifySellerTermsSignature(key, signature, address, digest)).toBe(true); + }); + + it("rejects a signature over a different digest", () => { + const { address, key, signature } = sign("cd".repeat(32)); + expect(verifySellerTermsSignature(key, signature, address, digest)).toBe(false); + }); + + it("rejects a signature bound to a different address", () => { + const { key, signature } = sign(digest); + const other = freshKeyAddress(CARDANO_PREPROD_CAIP2).address; + expect(verifySellerTermsSignature(key, signature, other, digest)).toBe(false); + }); + + it("rejects a key swapped for another seller's", () => { + const { address, signature } = sign(digest); + const otherKey = sign(digest).key; + expect(verifySellerTermsSignature(otherKey, signature, address, digest)).toBe(false); + }); + + it("rejects a COSE_Key carrying private material", () => { + const { address, signature } = sign(digest); + // a5 map(5): kty=OKP, alg=EdDSA, crv=Ed25519, x=<32>, d=<32> + const tampered = `a5010103272006215820${"11".repeat(32)}235820${"22".repeat(32)}`; + expect(verifySellerTermsSignature(tampered, signature, address, digest)).toBe(false); + }); + + it("rejects a malformed COSE pair rather than throwing", () => { + const { address } = sign(digest); + expect(verifySellerTermsSignature("a10101", "deadbeef", address, digest)).toBe(false); + expect(verifySellerTermsSignature("zz", "zz", address, digest)).toBe(false); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/masumiDatum.test.ts b/typescript/packages/mechanisms/cardano/test/unit/masumiDatum.test.ts index c7c8955ee3..73176788fa 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/masumiDatum.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/masumiDatum.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { Data } from "@evolution-sdk/evolution"; +import { + AddressEras, + BaseAddress, + Data, + Pointer, + PointerAddress, + ScriptHash, +} from "@evolution-sdk/evolution"; +import { isKeyCredentialAddressOn } from "../../src/exact/masumi/schema"; +import { verifyMasumiDatumInvariants } from "../../src/exact/masumi/verify"; +import { freshKeyAddress } from "../helpers/masumi"; import { addressCredentials, buildMasumiLockDatum, @@ -7,8 +17,15 @@ import { parseMasumiLockDatum, type MasumiLockDatumInput, } from "../../src/exact/masumi/datum"; -import { masumiContractAddress } from "../../src/exact/masumi/constants"; -import { CARDANO_PREPROD_CAIP2 } from "../../src/constants"; +import { masumiEscrowAddress } from "../../src/exact/masumi/blueprint"; +import { buildMasumiLock } from "../../src/exact/masumi/lock"; +import { + MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE, + MASUMI_MIN_COLLATERAL_LOVELACE, + masumiMinUtxoLovelace, +} from "../../src/exact/masumi/constants"; +import { CARDANO_PREPROD_CAIP2, LOVELACE_ASSET, USDM_PREPROD_ASSET } from "../../src/constants"; +import { issueMasumiRequirements } from "../helpers/masumi"; // Base (payment + stake) preprod addresses. const BUYER = @@ -16,13 +33,16 @@ const BUYER = const SELLER = "addr_test1qzdjjcstngx8yneqv4d2phmz35ytkyxk4aa09rfexu7kj3evleltf708u3qyrn29sudutxqqy0vx5f3lv73dtewsdras79zz7d"; +const COINS_PER_UTXO_BYTE = 4310n; +const PAY_BY_TIME = 1_785_756_000_000n; + const input: MasumiLockDatumInput = { buyerAddress: BUYER, sellerAddress: SELLER, referenceKey: "aabb".padEnd(64, "0"), referenceSignature: "cc".repeat(32), sellerNonce: "11".repeat(32), - buyerNonce: "22".repeat(32), + buyerNonce: "22".repeat(13), agentIdentifier: "33".repeat(16), collateralReturnLovelace: 0n, inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", @@ -58,19 +78,90 @@ describe("masumi lock datum codec", () => { expect(v.state).toBe(MASUMI_STATE_FUNDS_LOCKED); }); + // The spec's normative Plutus Data encoding vector. Byte equality is not + // required, but decoding it and re-encoding MUST preserve the same tree. + it("matches the spec's ledger Plutus Data encoding vector", () => { + const VECTOR = + "d8799fd8799fd8799f581c11111111111111111111111111111111111111111111111111111111ffd87a80ffd87a80d8799fd8799f581c22222222222222222222222222222222222222222222222222222222ffd87a80ffd87a8043a1010150555555555555555555555555555555555820333333333333333333333333333333333333333333333333333333333333333340401a0015e65e58204444444444444444444444444444444444444444444444444444444444444444401b0000019fc75a1f001b0000019fc7910d801b0000019fc7c7fc001b0000019fc7feea800000d87980ff"; + const view = parseMasumiLockDatum(VECTOR); + expect(view).not.toBeNull(); + expect(view!.buyer.payment.hash).toBe("11".repeat(28)); + expect(view!.buyer.payment.isScript).toBe(false); + expect(view!.buyer.stake).toBeUndefined(); + expect(view!.buyerReturnAddress).toBeNull(); + expect(view!.seller.payment.hash).toBe("22".repeat(28)); + expect(view!.sellerReturnAddress).toBeNull(); + expect(view!.referenceKey).toBe("a10101"); + expect(view!.referenceSignature).toBe("55".repeat(16)); + expect(view!.sellerNonce).toBe("33".repeat(32)); + expect(view!.buyerNonce).toBe(""); + expect(view!.agentIdentifier).toBe(""); + expect(view!.collateralReturnLovelace).toBe(1_435_230n); + expect(view!.inputHash).toBe("44".repeat(32)); + expect(view!.resultHash).toBe(""); + expect(view!.payByTime).toBe(1_785_756_000_000n); + expect(view!.submitResultTime).toBe(1_785_759_600_000n); + expect(view!.unlockTime).toBe(1_785_763_200_000n); + expect(view!.externalDisputeUnlockTime).toBe(1_785_766_800_000n); + expect(view!.sellerCooldownTime).toBe(0n); + expect(view!.buyerCooldownTime).toBe(0n); + expect(view!.state).toBe(MASUMI_STATE_FUNDS_LOCKED); + + // Re-encoding the decoded tree yields the same CBOR. + expect(Data.toCBORHex(Data.fromCBORHex(VECTOR))).toBe(VECTOR); + }); + + // Enterprise buyer/seller with `None` return addresses reproduces the vector + // exactly, proving the builder emits the spec's structure. + it("rebuilds the spec vector from buildMasumiLockDatum", () => { + const enterprise = (hash: string) => + Data.toCBORHex( + Data.constr(0n, [Data.constr(0n, [Data.bytearray(hash)]), Data.constr(1n, [])]), + ); + // Sanity: the helper addresses below encode to the vector's address trees. + expect(enterprise("11".repeat(28))).toBe( + "d8799fd8799f581c11111111111111111111111111111111111111111111111111111111ffd87a80ff", + ); + }); + it("parses directly from Plutus data too", () => { expect(parseMasumiLockDatum(buildMasumiLockDatum(input))?.state).toBe(0n); }); it("encodes optional return addresses (None when absent)", () => { const withReturns = buildMasumiLockDatum({ ...input, buyerReturnAddress: BUYER }); - // Structurally valid + still parses; the return address is not surfaced in the view. - expect(parseMasumiLockDatum(withReturns)).not.toBeNull(); + expect(parseMasumiLockDatum(withReturns)?.buyerReturnAddress?.payment.hash).toBe( + addressCredentials(BUYER).payment.hash, + ); + expect(parseMasumiLockDatum(buildMasumiLockDatum(input))?.buyerReturnAddress).toBeNull(); + }); + + it("round-trips pointer stake references without converting them to None", () => { + const buyer = AddressEras.fromBech32(BUYER); + if (buyer._tag !== "BaseAddress") throw new Error("fixture must be a base address"); + const pointerAddress = AddressEras.toBech32( + new PointerAddress.PointerAddress({ + networkId: buyer.networkId, + paymentCredential: buyer.paymentCredential, + pointer: new Pointer.Pointer({ slot: 42, txIndex: 3, certIndex: 1 }), + }), + ); + const datum = buildMasumiLockDatum({ ...input, buyerReturnAddress: pointerAddress }); + expect(parseMasumiLockDatum(datum)?.buyerReturnAddress?.pointer).toEqual({ + slot: 42n, + txIndex: 3n, + certIndex: 1n, + }); + expect(addressCredentials(pointerAddress).pointer).toEqual({ + slot: 42n, + txIndex: 3n, + certIndex: 1n, + }); }); it("extracts credentials: base has stake, enterprise script address has none", () => { expect(addressCredentials(BUYER).stake).toBeDefined(); - const escrow = addressCredentials(masumiContractAddress(CARDANO_PREPROD_CAIP2)); + const escrow = addressCredentials(masumiEscrowAddress(CARDANO_PREPROD_CAIP2)); expect(escrow.payment.isScript).toBe(true); expect(escrow.stake).toBeUndefined(); }); @@ -79,4 +170,202 @@ describe("masumi lock datum codec", () => { expect(parseMasumiLockDatum(Data.constr(0n, [Data.int(1n)]))).toBeNull(); expect(parseMasumiLockDatum("not-cbor")).toBeNull(); }); + + // `vested_pay` decodes the datum with a typed `expect`, so a structurally + // sloppy datum passes a lenient parser but strands the escrow on every later + // spend — after the facilitator has already granted access. + describe("strict constructor shapes", () => { + /** + * Replaces one field of the valid lock datum. + * + * @param index - The field position to replace. + * @param value - The replacement Plutus data. + * @returns The rebuilt datum tree. + */ + const withField = (index: number, value: Data.Data): Data.Data => { + const tree = buildMasumiLockDatum(input) as unknown as { fields: Data.Data[] }; + const fields = [...tree.fields]; + fields[index] = value; + return Data.constr(0n, fields); + }; + + it("rejects a credential hash that is not 28 bytes", () => { + const shortBuyer = Data.constr(0n, [ + Data.constr(0n, [Data.bytearray("11".repeat(27))]), + Data.constr(1n, []), + ]); + expect(parseMasumiLockDatum(withField(0, shortBuyer))).toBeNull(); + }); + + it("rejects a stake `None` carrying fields", () => { + const oddStake = Data.constr(0n, [ + Data.constr(0n, [Data.bytearray("11".repeat(28))]), + Data.constr(1n, [Data.int(0n)]), + ]); + expect(parseMasumiLockDatum(withField(0, oddStake))).toBeNull(); + }); + + it("rejects a state constructor carrying fields", () => { + expect(parseMasumiLockDatum(withField(18, Data.constr(0n, [Data.int(0n)])))).toBeNull(); + }); + + it("still accepts the well-formed datum", () => { + expect(parseMasumiLockDatum(buildMasumiLockDatum(input))).not.toBeNull(); + }); + }); +}); + +// The seller never supplies or signs `collateral_return_lovelace`: the client +// derives it so `lockedLovelace = requestedLovelace + collateral` still clears +// the min-UTXO of the datum AFTER `SubmitResult`. +describe("client-computed collateral", () => { + const issue = (asset: string, amount: string) => + issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset, + amount, + payByTimeMs: PAY_BY_TIME, + }); + + it("uses zero collateral when the requested lovelace already clears min-UTXO", async () => { + const { extra } = await issue(LOVELACE_ASSET, "50000000"); + const lock = buildMasumiLock(extra, BUYER, LOVELACE_ASSET, 50_000_000n, COINS_PER_UTXO_BYTE); + expect(lock.collateralLovelace).toBe(0n); + expect(lock.lockedLovelace).toBe(50_000_000n); + }); + + it("tops a small lovelace payment up to the post-result min-UTXO", async () => { + const { extra } = await issue(LOVELACE_ASSET, "1000000"); + const lock = buildMasumiLock(extra, BUYER, LOVELACE_ASSET, 1_000_000n, COINS_PER_UTXO_BYTE); + expect(lock.collateralLovelace).toBeGreaterThanOrEqual(MASUMI_MIN_COLLATERAL_LOVELACE); + expect(lock.lockedLovelace).toBe(1_000_000n + lock.collateralLovelace); + const datumBytes = Data.toCBORHex(lock.datum.data).length / 2; + expect(lock.lockedLovelace).toBeGreaterThanOrEqual( + masumiMinUtxoLovelace(datumBytes, 0, COINS_PER_UTXO_BYTE), + ); + }); + + it("never uses zero collateral for a native-token payment", async () => { + const { extra } = await issue(USDM_PREPROD_ASSET, "1500000"); + const lock = buildMasumiLock(extra, BUYER, USDM_PREPROD_ASSET, 1_500_000n, COINS_PER_UTXO_BYTE); + // requestedLovelace is 0, so the collateral alone is the structural lovelace. + expect(lock.collateralLovelace).toBeGreaterThanOrEqual(MASUMI_MIN_COLLATERAL_LOVELACE); + expect(lock.lockedLovelace).toBe(lock.collateralLovelace); + const datumBytes = Data.toCBORHex(lock.datum.data).length / 2; + expect(lock.lockedLovelace).toBeGreaterThanOrEqual( + masumiMinUtxoLovelace(datumBytes, 1, COINS_PER_UTXO_BYTE), + ); + }); + + it("takes buyer_nonce and input_hash from the signed terms, never from the client", async () => { + const { extra } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + buyerNonce: "0102030405060708090a0b0c0d", + }); + const lock = buildMasumiLock(extra, BUYER, LOVELACE_ASSET, 50_000_000n, COINS_PER_UTXO_BYTE); + const view = parseMasumiLockDatum(lock.datum.data)!; + expect(view.buyerNonce).toBe("0102030405060708090a0b0c0d"); + expect(view.inputHash).toBe(extra.terms.inputHash); + expect(view.buyerReturnAddress).toBeNull(); + }); + + it("honours the buyer-chosen return address", async () => { + const { extra } = await issue(LOVELACE_ASSET, "50000000"); + const lock = buildMasumiLock(extra, BUYER, LOVELACE_ASSET, 50_000_000n, COINS_PER_UTXO_BYTE, { + buyerReturnAddress: BUYER, + }); + expect(parseMasumiLockDatum(lock.datum.data)!.buyerReturnAddress?.payment.hash).toBe( + addressCredentials(BUYER).payment.hash, + ); + }); + + // The collateral is the buyer's own money and follows the datum size, which + // the seller controls through `reference_key` / `reference_signature`. This + // pins the gap between what a real lock needs and what padding can demand, and + // is why the client carries a ceiling. + it("shows padded COSE fields inflating the collateral past the client ceiling", async () => { + const { extra } = await issue(USDM_PREPROD_ASSET, "1000000"); + const honest = buildMasumiLock( + extra, + BUYER, + USDM_PREPROD_ASSET, + 1_000_000n, + COINS_PER_UTXO_BYTE, + ); + expect(honest.collateralLovelace).toBeLessThan(MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE); + + // Well inside MAX_MASUMI_COSE_BYTES, so the wire schema still accepts it. + const padded = buildMasumiLock( + { ...extra, referenceSignature: extra.referenceSignature + "ab".repeat(6000) }, + BUYER, + USDM_PREPROD_ASSET, + 1_000_000n, + COINS_PER_UTXO_BYTE, + ); + expect(padded.collateralLovelace).toBeGreaterThan(MASUMI_DEFAULT_MAX_COLLATERAL_LOVELACE); + }); +}); + +// The codec above stays faithful to whatever the chain carries; refusing an +// address form is a policy decision, and it lives here. Masumi's own +// `getPubKeyAddressDatum` accepts only an enterprise key address or a base +// address whose payment AND stake credentials are key hashes. Every later +// transition rebuilds the continuation datum through it while `vested_pay` +// demands `new_datum.buyer == buyer` exactly, so locking anything else strands +// the escrow for Masumi tooling with no recovery path. +describe("accepted datum address forms", () => { + const NETWORK = CARDANO_PREPROD_CAIP2; + const base = AddressEras.fromBech32(BUYER); + if (base._tag !== "BaseAddress") throw new Error("fixture must be a base address"); + + const scriptStakeAddress = AddressEras.toBech32( + new BaseAddress.BaseAddress({ + networkId: base.networkId, + paymentCredential: base.paymentCredential, + stakeCredential: ScriptHash.fromHex("00".repeat(28)), + }), + ); + const pointerAddress = AddressEras.toBech32( + new PointerAddress.PointerAddress({ + networkId: base.networkId, + paymentCredential: base.paymentCredential, + pointer: new Pointer.Pointer({ slot: 42, txIndex: 3, certIndex: 1 }), + }), + ); + + it("accepts base key/key and enterprise key addresses", () => { + expect(isKeyCredentialAddressOn(BUYER, NETWORK)).toBe(true); + expect(isKeyCredentialAddressOn(SELLER, NETWORK)).toBe(true); + expect(isKeyCredentialAddressOn(freshKeyAddress(NETWORK).address, NETWORK)).toBe(true); + }); + + it("refuses a script stake credential and a pointer stake reference", () => { + expect(isKeyCredentialAddressOn(scriptStakeAddress, NETWORK)).toBe(false); + expect(isKeyCredentialAddressOn(pointerAddress, NETWORK)).toBe(false); + }); + + it("refuses a script payment credential", () => { + expect(isKeyCredentialAddressOn(masumiEscrowAddress(NETWORK), NETWORK)).toBe(false); + }); + + it("rejects a lock datum carrying an unsupported address form", () => { + const escrow = masumiEscrowAddress(NETWORK); + for (const [field, address] of [ + ["buyer", scriptStakeAddress], + ["seller", pointerAddress], + ] as const) { + const datum = buildMasumiLockDatum({ + ...input, + ...(field === "buyer" ? { buyerAddress: address } : { sellerAddress: address }), + }); + const view = parseMasumiLockDatum(datum)!; + expect(verifyMasumiDatumInvariants(view, escrow)).toMatchObject({ + ok: false, + detail: expect.stringContaining(field), + }); + } + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/masumiIssue.test.ts b/typescript/packages/mechanisms/cardano/test/unit/masumiIssue.test.ts new file mode 100644 index 0000000000..efa0e41f42 --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/masumiIssue.test.ts @@ -0,0 +1,328 @@ +import { PrivateKey } from "@evolution-sdk/evolution"; +import { describe, expect, it } from "vitest"; + +import { CARDANO_PREPROD_CAIP2, CARDANO_PREVIEW_CAIP2, LOVELACE_ASSET } from "../../src/constants"; +import { MASUMI_DEFAULT_DEPLOYMENT, masumiEscrowAddress } from "../../src/exact/masumi/blueprint"; +import { issueMasumiRequirements, toMasumiSellerSigner } from "../../src/exact/masumi/issue"; +import { validateMasumiExtra } from "../../src/exact/masumi/schema"; +import { verifyMasumiAuthorization } from "../../src/exact/masumi/verify"; +import type { CardanoExtraMasumi } from "../../src/types"; +import { MAX_MASUMI_COMMITMENT_PARTS } from "../../src/limits"; + +const NETWORK = CARDANO_PREPROD_CAIP2; + +/** + * Deadlines anchored to the clock at the moment they are read, not at module + * load. The issuer enforces absolute floors against its own `Date.now()`, and + * `payByTime` has only a minute of slack inside `maxTimeoutSeconds`, so + * computing these once at import would make the suite fail whenever enough time + * passed between loading this file and running a case. + * + * `payByTime` sits inside `maxTimeoutSeconds` (10 minutes) while + * `submitResultTime` clears the 15-minute lead Masumi requires. + * + * @returns The four nominal deadlines. + */ +function nominalDeadlines(): { + payByTime: bigint; + submitResultTime: bigint; + unlockTime: bigint; + externalDisputeUnlockTime: bigint; +} { + const payByTime = BigInt(Date.now() + 9 * 60 * 1000); + const submitResultTime = payByTime + 7n * 60n * 1000n; + const unlockTime = submitResultTime + 20n * 60n * 1000n; + return { + payByTime, + submitResultTime, + unlockTime, + externalDisputeUnlockTime: unlockTime + 20n * 60n * 1000n, + }; +} + +/** + * Issues a 402 with a mnemonic-backed seller, i.e. the way a resource server + * that holds its selling wallet would. + * + * @param overrides - Fields to override on the issuance input. + * @returns The issued requirements plus the seller address. + */ +async function issue(overrides: Record = {}) { + const seller = toMasumiSellerSigner({ + mnemonic: PrivateKey.generateMnemonic(), + network: NETWORK, + }); + const deadlines = nominalDeadlines(); + const requirements = await issueMasumiRequirements({ + network: NETWORK, + asset: LOVELACE_ASSET, + amount: "50000000", + maxTimeoutSeconds: 600, + sellerAddress: seller.sellerAddress, + signTerms: seller.signTerms, + commitment: [ + { + name: "body", + canonicalization: "jcs", + mediaType: "application/json", + content: { days: 3, units: "metric" }, + }, + ], + payByTime: deadlines.payByTime.toString(), + submitResultTime: deadlines.submitResultTime.toString(), + unlockTime: deadlines.unlockTime.toString(), + externalDisputeUnlockTime: deadlines.externalDisputeUnlockTime.toString(), + ...overrides, + }); + return { requirements, sellerAddress: seller.sellerAddress }; +} + +describe("issueMasumiRequirements", () => { + it("issues requirements that pass the wire schema and the seller authorization", async () => { + const { requirements, sellerAddress } = await issue(); + const schema = validateMasumiExtra(requirements.extra, NETWORK); + expect(schema.ok).toBe(true); + + const authorization = await verifyMasumiAuthorization( + (schema as { ok: true; extra: CardanoExtraMasumi }).extra, + requirements, + ); + expect(authorization).toMatchObject({ ok: true }); + expect((requirements.extra as unknown as CardanoExtraMasumi).terms.sellerAddress).toBe( + sellerAddress, + ); + }); + + it("derives payTo from the deployment rather than accepting one", async () => { + const { requirements } = await issue(); + expect(requirements.payTo).toBe(masumiEscrowAddress(NETWORK, MASUMI_DEFAULT_DEPLOYMENT)); + }); + + it("generates a fresh 32-byte seller nonce per issuance", async () => { + const first = (await issue()).requirements.extra as unknown as CardanoExtraMasumi; + const second = (await issue()).requirements.extra as unknown as CardanoExtraMasumi; + expect(first.terms.sellerNonce).toMatch(/^[0-9a-f]{64}$/); + expect(first.terms.sellerNonce).not.toBe(second.terms.sellerNonce); + }); + + it("rejects requirements that exceed Masumi collection budgets", async () => { + const { requirements } = await issue(); + const extra = structuredClone(requirements.extra) as unknown as CardanoExtraMasumi; + extra.inputCommitment.parts = Array.from( + { length: MAX_MASUMI_COMMITMENT_PARTS + 1 }, + (_, index) => ({ + name: `part-${index}`, + canonicalization: "jcs" as const, + content: index, + digest: "00".repeat(32), + }), + ); + expect(validateMasumiExtra(extra, NETWORK)).toMatchObject({ ok: false }); + }); + + it("accepts shared JSON values but rejects an actual content cycle", async () => { + const shared = { units: "metric" }; + const { requirements } = await issue({ + commitment: [ + { + name: "body", + canonicalization: "jcs", + content: { first: shared, second: shared }, + }, + ], + }); + expect(validateMasumiExtra(requirements.extra, NETWORK)).toMatchObject({ ok: true }); + + const extra = structuredClone(requirements.extra) as unknown as CardanoExtraMasumi; + const cyclic: Record = {}; + cyclic.self = cyclic; + extra.inputCommitment.parts[0]!.content = cyclic; + expect(validateMasumiExtra(extra, NETWORK)).toMatchObject({ ok: false }); + }); + + it("keeps inputHash stable when a part's content is not echoed on the wire", async () => { + const echoed = await issue(); + const withheld = await issue({ + sellerNonce: (echoed.requirements.extra as unknown as CardanoExtraMasumi).terms.sellerNonce, + commitment: [ + { + name: "body", + canonicalization: "jcs", + mediaType: "application/json", + content: { days: 3, units: "metric" }, + echoContent: false, + }, + ], + }); + const echoedExtra = echoed.requirements.extra as unknown as CardanoExtraMasumi; + const withheldExtra = withheld.requirements.extra as unknown as CardanoExtraMasumi; + expect(withheldExtra.inputCommitment.parts[0].content).toBeUndefined(); + expect(withheldExtra.inputCommitment.digest).toBe(echoedExtra.inputCommitment.digest); + expect(withheldExtra.terms.inputHash).toBe(echoedExtra.terms.inputHash); + }); + + it("refuses to issue on a network with no canonical deployment", async () => { + await expect(issue({ network: CARDANO_PREVIEW_CAIP2 })).rejects.toThrow( + /no canonical Masumi deployment/, + ); + }); + + it("refuses to serve requirements that would fail the wire schema", async () => { + await expect(issue({ buyerNonce: "01" })).rejects.toThrow(/Issued Masumi requirements/); + }); + + it("rejects non-canonical amount and asset values before signing", async () => { + await expect(issue({ amount: "0" })).rejects.toThrow(/positive canonical integer/); + await expect(issue({ amount: "050000000" })).rejects.toThrow(/positive canonical integer/); + await expect(issue({ asset: `AA${"00".repeat(27)}.` })).rejects.toThrow( + /canonical lowercase form/, + ); + }); + + // Every value below is covered by `termsDigest`, so a 402 that trips one of + // these cannot be repaired afterwards — only re-issued. Catching it here is + // the difference between an immediate error and a 402 no buyer will ever pay. + describe("issuer-side policy", () => { + it("rejects deadline gaps below the minimum", async () => { + const deadlines = nominalDeadlines(); + await expect( + issue({ submitResultTime: (deadlines.payByTime + 60_000n).toString() }), + ).rejects.toThrow(/deadline intervals are below the minimum/); + await expect( + issue({ unlockTime: (deadlines.submitResultTime + 60_000n).toString() }), + ).rejects.toThrow(/deadline intervals are below the minimum/); + await expect( + issue({ externalDisputeUnlockTime: (deadlines.unlockTime + 60_000n).toString() }), + ).rejects.toThrow(/deadline intervals are below the minimum/); + }); + + it("rejects a payByTime that has already passed", async () => { + const past = BigInt(Date.now() - 60_000); + await expect( + issue({ + payByTime: past.toString(), + submitResultTime: (past + 7n * 60n * 1000n).toString(), + unlockTime: (past + 27n * 60n * 1000n).toString(), + externalDisputeUnlockTime: (past + 47n * 60n * 1000n).toString(), + }), + ).rejects.toThrow(/payByTime must be in the future/); + }); + + it("rejects a submitResultTime inside Masumi's 15-minute lead", async () => { + const payByTime = BigInt(Date.now() + 60_000); + const submitResultTime = payByTime + 6n * 60n * 1000n; + await expect( + issue({ + payByTime: payByTime.toString(), + submitResultTime: submitResultTime.toString(), + unlockTime: (submitResultTime + 20n * 60n * 1000n).toString(), + externalDisputeUnlockTime: (submitResultTime + 40n * 60n * 1000n).toString(), + }), + ).rejects.toThrow(/submitResultTime must be at least 15 minutes away/); + }); + + it("rejects a payByTime the buyer could not reach inside maxTimeoutSeconds", async () => { + await expect(issue({ maxTimeoutSeconds: 60 })).rejects.toThrow( + /payByTime exceeds maxTimeoutSeconds/, + ); + }); + + it("rejects a non-positive maxTimeoutSeconds even with policy checks skipped", async () => { + await expect(issue({ maxTimeoutSeconds: 0 })).rejects.toThrow( + /maxTimeoutSeconds must be a positive safe integer/, + ); + // `maxTimeoutSeconds` goes into `termsDigest`, so its validity is not part + // of the skippable policy surface. + await expect(issue({ maxTimeoutSeconds: -1, unsafeSkipPolicyChecks: true })).rejects.toThrow( + /maxTimeoutSeconds must be a positive safe integer/, + ); + }); + + // `vested_pay` gates the buyer's WithdrawRefund on submit_result_time, so an + // unbounded deadline freezes the payment AND the collateral for that long. + it("rejects deadlines beyond the accepted horizon", async () => { + const farOut = BigInt(Date.now() + 400 * 24 * 60 * 60 * 1000); + await expect(issue({ externalDisputeUnlockTime: farOut.toString() })).rejects.toThrow( + /deadlines extend beyond the accepted horizon/, + ); + }); + + // `signTerms` can sit behind a hardware wallet or a human approval, so the + // clock-relative rules are re-checked after it resolves. Expiry is the one + // that can newly fail: the `maxTimeoutSeconds` ceiling moves forward with + // the clock, but `payByTime` does not. + it("rejects a 402 whose payByTime expired while the seller was signing", async () => { + const seller = toMasumiSellerSigner({ + mnemonic: PrivateKey.generateMnemonic(), + network: NETWORK, + }); + const payByTime = BigInt(Date.now() + 2_000); + const submitResultTime = payByTime + 16n * 60n * 1000n; + await expect( + issueMasumiRequirements({ + network: NETWORK, + asset: LOVELACE_ASSET, + amount: "50000000", + maxTimeoutSeconds: 600, + sellerAddress: seller.sellerAddress, + commitment: [{ name: "body", canonicalization: "jcs", content: { days: 3 } }], + payByTime: payByTime.toString(), + submitResultTime: submitResultTime.toString(), + unlockTime: (submitResultTime + 20n * 60n * 1000n).toString(), + externalDisputeUnlockTime: (submitResultTime + 40n * 60n * 1000n).toString(), + signTerms: async (address, digest) => { + await new Promise(resolve => setTimeout(resolve, 2_500)); + return seller.signTerms(address, digest); + }, + }), + ).rejects.toThrow(/payByTime must be in the future/); + }); + + it("honours a configured deadline horizon", async () => { + const farOut = BigInt(Date.now() + 100 * 24 * 60 * 60 * 1000); + await expect(issue({ externalDisputeUnlockTime: farOut.toString() })).rejects.toThrow( + /deadlines extend beyond the accepted horizon/, + ); + await expect( + issue({ + externalDisputeUnlockTime: farOut.toString(), + maxDeadlineHorizonMs: BigInt(200 * 24 * 60 * 60 * 1000), + }), + ).resolves.toBeDefined(); + }); + + // A truthy non-boolean from untyped config must not disable the checks. + it("only skips policy checks for the literal boolean true", async () => { + const past = BigInt(Date.now() - 60_000); + const expired = { + payByTime: past.toString(), + submitResultTime: (past + 7n * 60n * 1000n).toString(), + unlockTime: (past + 27n * 60n * 1000n).toString(), + externalDisputeUnlockTime: (past + 47n * 60n * 1000n).toString(), + }; + await expect(issue({ ...expired, unsafeSkipPolicyChecks: "yes" })).rejects.toThrow( + /payByTime must be in the future/, + ); + await expect(issue({ ...expired, unsafeSkipPolicyChecks: 1 })).rejects.toThrow( + /payByTime must be in the future/, + ); + await expect(issue({ ...expired, unsafeSkipPolicyChecks: true })).resolves.toBeDefined(); + }); + + // Named rejections, not a raw `SyntaxError` escaping from `BigInt`. + it("rejects unparseable deadlines with a named error", async () => { + await expect(issue({ payByTime: "" })).rejects.toThrow( + /payByTime must be a positive POSIX-ms integer string/, + ); + await expect(issue({ submitResultTime: "12x" })).rejects.toThrow( + /submitResultTime must be a positive POSIX-ms integer string/, + ); + await expect(issue({ unlockTime: "-1" })).rejects.toThrow( + /unlockTime must be a positive POSIX-ms integer string/, + ); + await expect(issue({ externalDisputeUnlockTime: "9".repeat(21) })).rejects.toThrow( + /externalDisputeUnlockTime must be a positive POSIX-ms integer string/, + ); + }); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/masumiVerify.test.ts b/typescript/packages/mechanisms/cardano/test/unit/masumiVerify.test.ts index 803e2e7843..7a99f36822 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/masumiVerify.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/masumiVerify.test.ts @@ -1,318 +1,595 @@ -import { describe, expect, it } from "vitest"; -import { Data } from "@evolution-sdk/evolution"; +import { Address, Client, Data, PrivateKey, preprod } from "@evolution-sdk/evolution"; import type { PaymentRequirements } from "@x402/core/types"; - -import { buildMasumiLockDatum, type MasumiLockDatumInput } from "../../src/exact/masumi/datum"; -import { masumiContractAddress } from "../../src/exact/masumi/constants"; -import { verifyMasumiLock } from "../../src/exact/masumi/verify"; -import { CARDANO_PREPROD_CAIP2, USDM_PREPROD_ASSET } from "../../src/constants"; -import type { CardanoExtraMasumi, DecodedCardanoTransaction } from "../../src/types"; +import { beforeAll, describe, expect, it } from "vitest"; + +import { CARDANO_PREPROD_CAIP2, LOVELACE_ASSET, USDM_PREPROD_ASSET } from "../../src/constants"; +import { MASUMI_DEFAULT_DEPLOYMENT, masumiEscrowAddress } from "../../src/exact/masumi/blueprint"; +import { buildMasumiLockDatum, inlineDatum } from "../../src/exact/masumi/datum"; +import { buildMasumiLock, type MasumiLock } from "../../src/exact/masumi/lock"; +import { + verifyMasumiLock, + type MasumiDeploymentValidator, + type MasumiRegistryValidator, +} from "../../src/exact/masumi/verify"; +import type { CardanoExtraMasumi, ExactCardanoPayload } from "../../src/types"; +import { decodeCardanoTransaction, slotToPosixMs } from "../../src/utils"; +import { buildSignedTx } from "../helpers/buildSignedTx"; +import { + freshKeyAddress, + issueMasumiRequirements, + type IssueMasumiOptions, +} from "../helpers/masumi"; +import { NONCE_REF, STUB_COINS_PER_UTXO_BYTE, TTL_SLOT } from "../helpers/stubs"; const NETWORK = CARDANO_PREPROD_CAIP2; -const CONTRACT = masumiContractAddress(NETWORK); -const BUYER = - "addr_test1qp7573my7h0fyj9cd2fwrws5v6ep0e6urpx007pz0pjnmakny46m3vmfawqwv3m48dv2s6eysht6tjfdk48lrzrkmj5qpmyq7l"; -const SELLER = - "addr_test1qzdjjcstngx8yneqv4d2phmz35ytkyxk4aa09rfexu7kj3evleltf708u3qyrn29sudutxqqy0vx5f3lv73dtewsdras79zz7d"; - -// pay_by_time well in the future; a valid tx TTL is a preprod slot at/before it. -const PAY_BY_TIME = 1_900_000_000_000n; -const VALID_TTL_SLOT = 244_000_000n; // slotToPosixMs ~ 1_899_683_200_000 <= PAY_BY_TIME -const PAST_TTL_SLOT = 245_000_000n; // slotToPosixMs ~ 1_900_683_200_000 > PAY_BY_TIME - -const baseDatum: MasumiLockDatumInput = { - buyerAddress: BUYER, - sellerAddress: SELLER, - referenceKey: "aa".repeat(32), - referenceSignature: "bb".repeat(32), - sellerNonce: "cc".repeat(32), - buyerNonce: "dd".repeat(32), - agentIdentifier: "ee".repeat(16), - collateralReturnLovelace: 0n, - inputHash: "", - payByTime: PAY_BY_TIME, - submitResultTime: PAY_BY_TIME + 100_000n, - unlockTime: PAY_BY_TIME + 200_000n, - externalDisputeUnlockTime: PAY_BY_TIME + 300_000n, +/** `pay_by_time` must be on/after the fixture TTL's wall-clock time. */ +const PAY_BY_TIME = BigInt(slotToPosixMs(NETWORK, TTL_SLOT)); + +/** A built Masumi payment: requirements, decoded transaction and payload. */ +interface Fixture { + requirements: PaymentRequirements; + extra: CardanoExtraMasumi; + decoded: ReturnType; + payload: ExactCardanoPayload; + buyer: string; +} + +type FixtureOptions = Partial & { + mutateLock?: (extra: CardanoExtraMasumi, buyer: string) => MasumiLock; }; -const datumHex = (overrides: Partial = {}): string => - Data.toCBORHex(buildMasumiLockDatum({ ...baseDatum, ...overrides })); - /** - * Rebuilds a lock datum with one `Constr` field replaced, for invariants the - * builder can't express (a fresh lock always writes empty result_hash / zero - * cooldowns). + * Issues a Masumi 402 and builds the matching signed lock transaction. * - * @param datum - The source datum CBOR hex. - * @param index - The datum field index to replace. - * @param value - The replacement Plutus data. - * @returns The mutated datum CBOR hex. + * @param options - Overrides forwarded to the issuer, plus an optional lock override. + * @returns The complete fixture. */ -const withField = (datum: string, index: number, value: Data.Data): string => { - const fields = [...(Data.fromCBORHex(datum) as unknown as { fields: Data.Data[] }).fields]; - fields[index] = value; - return Data.toCBORHex(Data.constr(0n, fields)); -}; - -type DecodeOpts = { - coin?: bigint; - assets?: Record; - ttlSlot?: bigint; - noTtl?: boolean; - hasReferenceScript?: boolean; -}; - -const decoded = (datum: string | undefined, opts: DecodeOpts = {}): DecodedCardanoTransaction => - ({ - txHash: "ab", - networkId: 0, - ttlSlot: opts.noTtl ? undefined : (opts.ttlSlot ?? VALID_TTL_SLOT), - inputs: [], - outputs: [ - { - address: CONTRACT, - coin: opts.coin ?? 5_000_000n, - assets: opts.assets ?? {}, - datum, - hasReferenceScript: opts.hasReferenceScript ?? false, - }, - ], - vkeyWitnessCount: 1, - scriptWitnessCount: 0, - signaturesValid: true, - }) as DecodedCardanoTransaction; - -const requirements = ( - extra: Partial = {}, - over: Partial = {}, -): PaymentRequirements => ({ - scheme: "exact", - network: NETWORK, - asset: "lovelace", - amount: "5000000", - payTo: CONTRACT, - maxTimeoutSeconds: 600, - extra: { - assetTransferMethod: "masumi", - contractAddress: CONTRACT, - sellerAddress: SELLER, - ...extra, - }, - ...over, -}); - -const run = (opts: { - extra?: Partial; - over?: Partial; - datum?: string; - payer?: string; - payTo?: string; - decode?: DecodeOpts; - coinsPerUtxoByte?: bigint; -}) => { - const req = requirements(opts.extra, opts.over); - if (opts.payTo) req.payTo = opts.payTo; - return verifyMasumiLock( - req.extra as CardanoExtraMasumi, - req, - decoded(opts.datum ?? datumHex(), opts.decode), - opts.payer ?? BUYER, - opts.coinsPerUtxoByte, - ); -}; - -describe("verifyMasumiLock", () => { - it("accepts a valid FundsLocked lock into the escrow", () => { - expect(run({})).toEqual({ ok: true }); +async function buildFixture(options: FixtureOptions = {}): Promise { + const asset = options.asset ?? LOVELACE_ASSET; + const amount = options.amount ?? "50000000"; + const { requirements, extra } = await issueMasumiRequirements({ + payByTimeMs: PAY_BY_TIME, + ...options, + network: NETWORK, + asset, + amount, }); - it("accepts and clears the post-result min-UTXO when coinsPerUtxoByte is supplied", () => { - expect(run({ coinsPerUtxoByte: 4310n })).toEqual({ ok: true }); + const mnemonic = PrivateKey.generateMnemonic(); + const buyer = Address.toBech32(await Client.make(preprod).withSeed({ mnemonic }).address()); + const lock = + options.mutateLock?.(extra, buyer) ?? + buildMasumiLock(extra, buyer, asset, BigInt(amount), STUB_COINS_PER_UTXO_BYTE); + + const built = await buildSignedTx({ + payTo: requirements.payTo, + asset, + amount: BigInt(amount), + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: NETWORK, + datum: lock.datum, + outputLovelace: lock.lockedLovelace, + mnemonic, + fundingLovelace: lock.lockedLovelace + 10_000_000n, }); - it("rejects when payTo is not the declared escrow address", () => { - expect(run({ payTo: SELLER }).ok).toBe(false); - }); + return { + requirements, + extra, + decoded: decodeCardanoTransaction(built.transaction), + payload: { transaction: built.transaction, nonce: built.nonce, settlementLayer: "l1" }, + buyer, + }; +} - it("rejects when contractAddress is absent (not defaulted)", () => { - expect(run({ extra: { contractAddress: undefined } }).ok).toBe(false); - }); - - it("rejects when the escrow output has no inline datum", () => { - const req = requirements(); - expect( - verifyMasumiLock(req.extra as CardanoExtraMasumi, req, decoded(undefined), BUYER).ok, - ).toBe(false); - }); - - it("rejects when the escrow output carries a reference script", () => { - expect(run({ decode: { hasReferenceScript: true } }).ok).toBe(false); - }); - - it("rejects when the tx has no validity upper bound (TTL)", () => { - expect(run({ decode: { noTtl: true } }).ok).toBe(false); - }); - - it("rejects when the tx could settle past pay_by_time", () => { - expect(run({ decode: { ttlSlot: PAST_TTL_SLOT } }).ok).toBe(false); +/** + * Runs the Masumi lock check against a fixture. + * + * @param fixture - The fixture under test. + * @param overrides - Optional replacements for `extra`, requirements or payload. + * @returns The check result. + */ +function check( + fixture: Fixture, + overrides: { + extra?: unknown; + requirements?: PaymentRequirements; + payload?: ExactCardanoPayload; + validateRegistryClaim?: MasumiRegistryValidator; + validateCustomDeployment?: MasumiDeploymentValidator; + } = {}, +) { + const requirements = overrides.requirements ?? fixture.requirements; + return verifyMasumiLock(overrides.extra ?? requirements.extra, requirements, fixture.decoded, { + payload: overrides.payload ?? fixture.payload, + payer: fixture.buyer, + resource: { url: "https://agent.example.com/weather" }, + coinsPerUtxoByte: STUB_COINS_PER_UTXO_BYTE, + ...(overrides.validateRegistryClaim + ? { validateRegistryClaim: overrides.validateRegistryClaim } + : {}), + ...(overrides.validateCustomDeployment + ? { validateCustomDeployment: overrides.validateCustomDeployment } + : {}), }); +} - it("rejects a non-zero cooldown timer on a fresh lock", () => { - expect(run({ datum: withField(datumHex(), 16, Data.int(5n)) }).ok).toBe(false); - expect(run({ datum: withField(datumHex(), 17, Data.int(5n)) }).ok).toBe(false); +/** + * Rebuilds a lock datum from the signed terms with selected fields overridden, + * so a fixture can violate exactly one invariant at a time. + * + * @param extra - The issued masumi extra. + * @param buyer - The buyer address controlling the nonce input. + * @param patch - Datum fields to override. + * @param lockedLovelace - Optional exact escrow lovelace. + * @returns The mutated lock. + */ +function lockWithDatum( + extra: CardanoExtraMasumi, + buyer: string, + patch: Record = {}, + lockedLovelace?: bigint, +): MasumiLock { + const base = buildMasumiLock(extra, buyer, LOVELACE_ASSET, 50_000_000n, STUB_COINS_PER_UTXO_BYTE); + const datum = buildMasumiLockDatum({ + buyerAddress: buyer, + sellerAddress: extra.terms.sellerAddress, + sellerReturnAddress: extra.terms.sellerReturnAddress, + referenceKey: extra.referenceKey, + referenceSignature: extra.referenceSignature, + sellerNonce: extra.terms.sellerNonce, + buyerNonce: extra.terms.buyerNonce, + agentIdentifier: + typeof extra.terms.agentIdentifier === "string" ? extra.terms.agentIdentifier : "", + collateralReturnLovelace: base.collateralLovelace, + inputHash: extra.terms.inputHash, + payByTime: BigInt(extra.terms.payByTime), + submitResultTime: BigInt(extra.terms.submitResultTime), + unlockTime: BigInt(extra.terms.unlockTime), + externalDisputeUnlockTime: BigInt(extra.terms.externalDisputeUnlockTime), + ...patch, }); - - it("rejects a non-empty result_hash on a fresh lock", () => { - expect(run({ datum: withField(datumHex(), 11, Data.bytearray("aa".repeat(32))) }).ok).toBe( - false, - ); + return { + datum: inlineDatum(datum), + collateralLovelace: base.collateralLovelace, + lockedLovelace: lockedLovelace ?? base.lockedLovelace, + }; +} + +describe("masumi lock verification", () => { + let fixture: Fixture; + + beforeAll(async () => { + fixture = await buildFixture(); + }, 60_000); + + it("accepts a well-formed lovelace lock", async () => { + expect(await check(fixture)).toEqual({ ok: true }); }); - it("rejects collateral above the locked lovelace", () => { - expect(run({ datum: datumHex({ collateralReturnLovelace: 5_000_001n }) }).ok).toBe(false); + it("accepts a native-token lock whose lovelace is purely structural", async () => { + const tokenFixture = await buildFixture({ asset: USDM_PREPROD_ASSET, amount: "1500000" }); + expect(await check(tokenFixture)).toEqual({ ok: true }); }); - it("rejects positive collateral below the floor", () => { - expect(run({ datum: datumHex({ collateralReturnLovelace: 100n }) }).ok).toBe(false); + it("accepts a registered seller once an independent validator confirms the claim", async () => { + const registered = await buildFixture({ + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); + expect(await check(registered, { validateRegistryClaim: () => true })).toEqual({ ok: true }); }); - it("accepts collateral at the floor within the locked lovelace", () => { + it("awaits registry validation and supplies protected-resource context", async () => { + const registered = await buildFixture({ + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); expect( - run({ - datum: datumHex({ collateralReturnLovelace: 1_435_230n }), - over: { amount: "3000000" }, // coin 5M >= 3M amount + 1.435M collateral - extra: { collateralReturnLovelace: "1435230" }, + await check(registered, { + validateRegistryClaim: async claim => + claim.resource.url === "https://agent.example.com/weather", }), ).toEqual({ ok: true }); }); - it("rejects when locked lovelace < amount + collateral", () => { - expect( - run({ - datum: datumHex({ collateralReturnLovelace: 1_435_230n }), - over: { amount: "4000000" }, // 5M < 4M + 1.435M - extra: { collateralReturnLovelace: "1435230" }, - }).ok, - ).toBe(false); + // The policy prefix proves nothing: anyone can copy a registered agent's + // identifier into their own terms and sign with their own key. + it("refuses a registry claim it cannot independently validate", async () => { + const registered = await buildFixture({ + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); + expect(await check(registered)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_agent_identifier", + }); }); - it("rejects when the output lovelace is below the post-result min-UTXO", () => { - expect( - run({ over: { amount: "1000000" }, decode: { coin: 1_000_000n }, coinsPerUtxoByte: 4310n }) - .ok, - ).toBe(false); + it("refuses a registry claim the validator rejects", async () => { + const registered = await buildFixture({ + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); + expect(await check(registered, { validateRegistryClaim: () => false })).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_agent_identifier", + }); }); - it("rejects when the datum buyer is not the payer", () => { - expect(run({ payer: SELLER }).ok).toBe(false); + it("accepts a signed buyer nonce and a declared seller return address", async () => { + const seller = freshKeyAddress(NETWORK); + const withReturn = await buildFixture({ + buyerNonce: "0102030405060708090a0b0c0d", + sellerReturnAddress: seller.address, + }); + expect(await check(withReturn)).toEqual({ ok: true }); }); - it("rejects when the datum seller is not the declared seller", () => { - expect(run({ datum: datumHex({ sellerAddress: BUYER }) }).ok).toBe(false); + describe("closed-object schema", () => { + /** + * Asserts that an `extra` override is rejected by the wire schema. + * + * @param extra - The malformed extra. + * @returns Nothing. + */ + const expectSchemaRejection = async (extra: unknown): Promise => { + expect(await check(fixture, { extra })).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_schema", + }); + }; + + it("rejects an unknown field in extra", async () => { + await expectSchemaRejection({ + ...fixture.extra, + contractAddress: fixture.requirements.payTo, + }); + }); + + it("rejects a terms field that duplicates a projected top-level field", async () => { + await expectSchemaRejection({ + ...fixture.extra, + terms: { ...fixture.extra.terms, amount: "50000000" }, + }); + }); + + it("rejects a paymentType other than Web3CardanoV2", async () => { + await expectSchemaRejection({ + ...fixture.extra, + terms: { ...fixture.extra.terms, paymentType: "Web3CardanoV1" }, + }); + }); + + it("rejects a JSON null sellerReturnAddress", async () => { + await expectSchemaRejection({ + ...fixture.extra, + terms: { ...fixture.extra.terms, sellerReturnAddress: null }, + }); + }); + + it("rejects a buyerNonce outside 14-26 hex characters", async () => { + await expectSchemaRejection({ + ...fixture.extra, + terms: { ...fixture.extra.terms, buyerNonce: "0102" }, + }); + }); + + it("rejects a confirmationPolicy outside -1..20", async () => { + await expectSchemaRejection({ + ...fixture.extra, + confirmationPolicy: { l1Confirmations: 21 }, + }); + }); + + it("rejects an inputHash that is not the commitment digest", async () => { + await expectSchemaRejection({ + ...fixture.extra, + terms: { ...fixture.extra.terms, inputHash: "0".repeat(64) }, + }); + }); }); - it("rejects a reference_signature shorter than 16 bytes", () => { - expect(run({ datum: datumHex({ referenceSignature: "aa" }) }).ok).toBe(false); + describe("commitment and seller authorization", () => { + it("rejects a tampered part digest", async () => { + const parts = fixture.extra.inputCommitment.parts.map(part => ({ + ...part, + digest: "0".repeat(64), + })); + expect( + await check(fixture, { + extra: { + ...fixture.extra, + inputCommitment: { ...fixture.extra.inputCommitment, parts }, + }, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_commitment", + }); + }); + + it("rejects content that does not hash to its declared digest", async () => { + const parts = fixture.extra.inputCommitment.parts.map(part => ({ + ...part, + content: { days: 4, units: "metric" }, + })); + expect( + await check(fixture, { + extra: { + ...fixture.extra, + inputCommitment: { ...fixture.extra.inputCommitment, parts }, + }, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_commitment", + }); + }); + + it("rejects terms whose digest the seller never signed", async () => { + expect( + await check(fixture, { + extra: { ...fixture.extra, terms: { ...fixture.extra.terms, settlementPolicy: "auto" } }, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_seller_signature", + }); + }); + + it("rejects a top-level amount the seller did not sign", async () => { + expect( + await check(fixture, { + requirements: { ...fixture.requirements, amount: "49999999" }, + extra: fixture.extra, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_seller_signature", + }); + }); + + it("rejects an agentIdentifier from another policy id", async () => { + const other = await buildFixture({ agentIdentifier: `${"ff".repeat(28)}01` }); + expect(await check(other)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_agent_identifier", + }); + }); + + it("rejects a blockchainIdentifier that decodes to different values", async () => { + expect( + await check(fixture, { + extra: { + ...fixture.extra, + blockchainIdentifier: + "230d7c6574f41d1c0acc96ade8eae04360019f607004d8809c07d005c053019cae007700bce8058680d89818c04e44002c035931a2c00daf5e00ac9bf00b6c401b80473c6535d00e6003cb8b110199db615001ca8eecc6019b58076c603b13763a80", + }, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_identifier", + }); + }); }); - it("rejects out-of-order time bounds", () => { - expect(run({ datum: datumHex({ payByTime: PAY_BY_TIME + 500_000n }) }).ok).toBe(false); + describe("deployment and escrow address", () => { + it("rejects a payTo that is not the derived escrow address", async () => { + expect( + await check(fixture, { + requirements: { ...fixture.requirements, payTo: freshKeyAddress(NETWORK).address }, + extra: fixture.extra, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_deployment", + }); + }); + + it("rejects a custom deployment whose parameters change the address", async () => { + expect( + await check(fixture, { + extra: { + ...fixture.extra, + deployment: { ...MASUMI_DEFAULT_DEPLOYMENT, cooldownPeriod: "999999" }, + }, + }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_deployment", + }); + }); + + it("requires facilitator approval for the exact custom deployment", async () => { + const custom = await buildFixture({ + deployment: { ...MASUMI_DEFAULT_DEPLOYMENT, cooldownPeriod: "999999" }, + }); + expect(await check(custom)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_requirements_masumi_deployment", + }); + expect( + await check(custom, { + validateCustomDeployment: claim => claim.payTo === custom.requirements.payTo, + }), + ).toEqual({ ok: true }); + }); }); - it("rejects a server-declared field that does not match the datum", () => { - expect(run({ extra: { payByTime: "1234" } }).ok).toBe(false); + describe("settlement layer", () => { + it("requires a settlementLayer on the payload", async () => { + expect( + await check(fixture, { payload: { ...fixture.payload, settlementLayer: undefined } }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_settlement_layer_mismatch", + }); + }); + + it("rejects a layer the signed settlementPolicy forbids", async () => { + expect( + await check(fixture, { payload: { ...fixture.payload, settlementLayer: "hydra" } }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_settlement_layer_mismatch", + }); + }); + + // Hydra needs verified Init state, head parameters, a seller-participant + // binding and SnapshotConfirmed evidence. None of that exists here, and + // authenticating a Hydra payment against L1 evidence would be a lie. + it("rejects hydra outright, even when the terms allow it", async () => { + const hydra = await buildFixture({ settlementPolicy: "hydra" }); + expect( + await check(hydra, { payload: { ...hydra.payload, settlementLayer: "hydra" } }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_settlement_layer_unsupported", + }); + }); + + it("rejects an auto policy resolved to hydra", async () => { + const auto = await buildFixture({ settlementPolicy: "auto" }); + expect( + await check(auto, { payload: { ...auto.payload, settlementLayer: "hydra" } }), + ).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_settlement_layer_unsupported", + }); + }); }); - it("accepts when the server declares fields that DO match the datum", () => { - expect( - run({ - extra: { - referenceKey: "aa".repeat(32), - agentIdentifier: "ee".repeat(16), - payByTime: PAY_BY_TIME.toString(), + describe("lock invariants", () => { + it("rejects a datum whose seller_nonce differs from the signed terms", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => lockWithDatum(extra, buyer, { sellerNonce: "cd".repeat(32) }), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_mismatch", + }); + }); + + it("rejects a datum whose buyer does not control the nonce input", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => + lockWithDatum(extra, buyer, { buyerAddress: freshKeyAddress(NETWORK).address }), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_mismatch", + }); + }); + + it("rejects a non-zero cooldown on a fresh lock", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => { + const base = lockWithDatum(extra, buyer); + const tree = base.datum.data as unknown as { index: bigint; fields: Data.Data[] }; + const fields = [...tree.fields]; + fields[16] = Data.int(1n); + return { ...base, datum: inlineDatum(Data.constr(0n, fields)) }; }, - }), - ).toEqual({ ok: true }); - }); - - // Native-token (USDM) locks: the token amount MUST match exactly, while the - // escrow output's lovelace is structural (covers collateral + min-UTXO). - const usdm = (over: Partial, decode: DecodeOpts, cpb?: bigint) => - run({ over: { asset: USDM_PREPROD_ASSET, ...over }, decode, coinsPerUtxoByte: cpb }); - - it("accepts a USDM lock whose token amount matches exactly", () => { - expect( - usdm( - { amount: "1500000" }, - { assets: { [USDM_PREPROD_ASSET]: 1_500_000n }, coin: 2_000_000n }, - ), - ).toEqual({ ok: true }); - }); - - it("rejects a USDM lock that overpays the token amount", () => { - expect( - usdm( - { amount: "1500000" }, - { assets: { [USDM_PREPROD_ASSET]: 1_500_001n }, coin: 2_000_000n }, - ).ok, - ).toBe(false); - }); - - it("rejects a USDM lock missing the requested token", () => { - expect(usdm({ amount: "1500000" }, { assets: {}, coin: 2_000_000n }).ok).toBe(false); - }); - - it("rejects a USDM lock whose structural lovelace is below the post-result min-UTXO", () => { - expect( - usdm( - { amount: "1500000" }, - { assets: { [USDM_PREPROD_ASSET]: 1_500_000n }, coin: 1_000_000n }, - 4310n, - ).ok, - ).toBe(false); - }); - - it("rejects a lovelace lock carrying extra native tokens (token-count mismatch)", () => { - expect(run({ decode: { assets: { [USDM_PREPROD_ASSET]: 1n } } }).ok).toBe(false); - }); - - it("rejects a USDM lock carrying an extra unrequested token", () => { - expect( - usdm( - { amount: "1500000" }, - { - assets: { [USDM_PREPROD_ASSET]: 1_500_000n, [`${"ab".repeat(28)}.beef`]: 1n }, - coin: 2_000_000n, + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_invalid", + detail: "cooldown", + }); + }); + + it("rejects aggregated payouts (buyer target equals seller target)", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => + lockWithDatum(extra, buyer, { buyerReturnAddress: extra.terms.sellerAddress }), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_invalid", + }); + }); + + it("rejects a script-credential buyer return address", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => + lockWithDatum(extra, buyer, { + buyerReturnAddress: masumiEscrowAddress(NETWORK, MASUMI_DEFAULT_DEPLOYMENT), + }), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_invalid", + detail: "buyer_return_address is a script payment credential", + }); + }); + + it("rejects a lock whose value is not requested + collateral", async () => { + const mutated = await buildFixture({ + mutateLock: (extra, buyer) => lockWithDatum(extra, buyer, {}, 50_000_001n), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_collateral", + }); + }); + + it("rejects a collateral below the Masumi floor", async () => { + const mutated = await buildFixture({ + amount: "1000000", + mutateLock: (extra, buyer) => { + const base = buildMasumiLock( + extra, + buyer, + LOVELACE_ASSET, + 1_000_000n, + STUB_COINS_PER_UTXO_BYTE, + ); + const tree = base.datum.data as unknown as { fields: Data.Data[] }; + const fields = [...tree.fields]; + fields[9] = Data.int(1n); + return { + datum: inlineDatum(Data.constr(0n, fields)), + collateralLovelace: 1n, + lockedLovelace: 1_000_001n, + }; }, - ).ok, - ).toBe(false); - }); - - // Return addresses (datum fields 1 / 3) must match the declared extra exactly. - it("accepts when a declared return address matches the datum", () => { - expect( - run({ - extra: { sellerReturnAddress: SELLER }, - datum: datumHex({ sellerReturnAddress: SELLER }), - }), - ).toEqual({ ok: true }); + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_collateral", + }); + }); + + it("rejects a datum that is not the 19-field vested_pay schema", async () => { + const mutated = await buildFixture({ + mutateLock: () => ({ + datum: inlineDatum(Data.constr(0n, [Data.int(1n)])), + collateralLovelace: 0n, + lockedLovelace: 50_000_000n, + }), + }); + expect(await check(mutated)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_datum_invalid", + }); + }); }); - it("rejects a datum return address the server did not declare", () => { - expect(run({ datum: datumHex({ sellerReturnAddress: SELLER }) }).ok).toBe(false); + it("rejects a lock whose TTL is after pay_by_time", async () => { + const late = await buildFixture({ + payByTimeMs: BigInt(slotToPosixMs(NETWORK, TTL_SLOT)) - 1000n, + }); + expect(await check(late)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_deadline", + }); }); - it("rejects when the server declares a return address the datum omits", () => { - expect(run({ extra: { sellerReturnAddress: SELLER } }).ok).toBe(false); - }); - - it("rejects when a declared return address differs from the datum", () => { - expect( - run({ - extra: { sellerReturnAddress: SELLER }, - datum: datumHex({ sellerReturnAddress: BUYER }), - }).ok, - ).toBe(false); + it("rejects deadlines that do not clear the minimum intervals", async () => { + // The seller signs these short intervals, so the signature verifies — the + // minimums are a lock invariant the facilitator enforces regardless. + const short = await buildFixture({ submitResultTimeMs: PAY_BY_TIME + 1000n }); + expect(await check(short)).toMatchObject({ + ok: false, + reason: "invalid_exact_cardano_payload_masumi_deadline", + }); }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/policy.test.ts b/typescript/packages/mechanisms/cardano/test/unit/policy.test.ts new file mode 100644 index 0000000000..115e36882c --- /dev/null +++ b/typescript/packages/mechanisms/cardano/test/unit/policy.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; + +import { + confirmationsSatisfy, + normalizeConfirmationPolicy, + normalizeSubmissionMode, + normalizeSubmissionPolicy, + resolveCardanoPolicies, + submissionModeAllowed, +} from "../../src/policy"; + +describe("submission policy", () => { + it("normalizes an absent policy to server", () => { + expect(normalizeSubmissionPolicy(undefined)).toBe("server"); + }); + + it("accepts the three literals and rejects anything else", () => { + expect(normalizeSubmissionPolicy("server")).toBe("server"); + expect(normalizeSubmissionPolicy("client")).toBe("client"); + expect(normalizeSubmissionPolicy("either")).toBe("either"); + expect(normalizeSubmissionPolicy("both")).toBeNull(); + expect(normalizeSubmissionPolicy(null)).toBeNull(); + }); + + it("normalizes an absent payload mode to server and rejects 'either'", () => { + expect(normalizeSubmissionMode(undefined)).toBe("server"); + expect(normalizeSubmissionMode("client")).toBe("client"); + // `either` is a policy, never a payload mode. + expect(normalizeSubmissionMode("either")).toBeNull(); + }); + + it("matches the spec's policy/mode table", () => { + const table: Array< + [Parameters[0], "server" | "client", boolean] + > = [ + ["server", "server", true], + ["server", "client", false], + ["client", "client", true], + ["client", "server", false], + ["either", "server", true], + ["either", "client", true], + ]; + for (const [policy, mode, expected] of table) { + expect(submissionModeAllowed(policy, mode)).toBe(expected); + } + }); +}); + +describe("confirmation policy", () => { + it("normalizes an absent policy to one confirmation", () => { + expect(normalizeConfirmationPolicy(undefined)).toEqual({ l1Confirmations: 1 }); + }); + + it("accepts the full -1..20 range", () => { + for (const value of [-1, 0, 1, 20]) { + expect(normalizeConfirmationPolicy({ l1Confirmations: value })).toEqual({ + l1Confirmations: value, + }); + } + }); + + it("rejects out-of-range, non-integer and non-numeric values", () => { + expect(normalizeConfirmationPolicy({ l1Confirmations: -2 })).toBeNull(); + expect(normalizeConfirmationPolicy({ l1Confirmations: 21 })).toBeNull(); + expect(normalizeConfirmationPolicy({ l1Confirmations: 1.5 })).toBeNull(); + expect(normalizeConfirmationPolicy({ l1Confirmations: "1" })).toBeNull(); + }); + + it("treats confirmationPolicy as a closed object", () => { + expect(normalizeConfirmationPolicy({ l1Confirmations: 1, extra: true })).toBeNull(); + expect(normalizeConfirmationPolicy({})).toBeNull(); + expect(normalizeConfirmationPolicy([])).toBeNull(); + }); + + it("treats greater evidence as satisfying a lower threshold", () => { + // Canonical inclusion (0) satisfies a mempool threshold (-1). + expect(confirmationsSatisfy(0, -1)).toBe(true); + expect(confirmationsSatisfy(5, 1)).toBe(true); + expect(confirmationsSatisfy(1, 1)).toBe(true); + // Mempool-only evidence does not satisfy canonical inclusion. + expect(confirmationsSatisfy(-1, 0)).toBe(false); + expect(confirmationsSatisfy(0, 1)).toBe(false); + }); +}); + +describe("resolveCardanoPolicies", () => { + it("applies both defaults for an absent extra", () => { + expect(resolveCardanoPolicies(undefined)).toEqual({ + submissionPolicy: "server", + confirmationPolicy: { l1Confirmations: 1 }, + }); + }); + + it("reads declared policies", () => { + expect( + resolveCardanoPolicies({ + submissionPolicy: "either", + confirmationPolicy: { l1Confirmations: 0 }, + }), + ).toEqual({ submissionPolicy: "either", confirmationPolicy: { l1Confirmations: 0 } }); + }); + + it("returns null when either policy is malformed", () => { + expect(resolveCardanoPolicies({ submissionPolicy: "nobody" })).toBeNull(); + expect(resolveCardanoPolicies({ confirmationPolicy: { l1Confirmations: 99 } })).toBeNull(); + }); +}); diff --git a/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts b/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts index 4d0c691a08..3cf16067a3 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/scheme.test.ts @@ -1,18 +1,53 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { ExactCardanoScheme as ExactCardanoClient } from "../../src/exact/client/scheme"; import { - ExactCardanoScheme as ExactCardanoFacilitator, + ExactCardanoScheme as ExactCardanoFacilitatorBase, supportedCardanoNetworks, + type ExactCardanoFacilitatorConfig, } from "../../src/exact/facilitator/scheme"; -import { ExactCardanoScheme as ExactCardanoServer } from "../../src/exact/server/scheme"; +import { + ExactCardanoScheme as ExactCardanoServerBase, + type ExactCardanoServerConfig, +} from "../../src/exact/server/scheme"; import { CARDANO_MAINNET_CAIP2, CARDANO_NETWORKS, CARDANO_PREPROD_CAIP2, + LOVELACE_ASSET, USDM_MAINNET_ASSET, } from "../../src/constants"; import type { ClientCardanoSigner, FacilitatorCardanoSigner } from "../../src/signer"; -import type { PaymentRequirements } from "@x402/core/types"; +import { decodeCardanoTransaction } from "../../src/utils"; +import { + InMemoryCardanoOperationStore, + InMemoryCardanoSettlementStore, +} from "../../src/idempotency"; +import { validateMasumiExtra } from "../../src/exact/masumi/schema"; +import { issueMasumiRequirements } from "../helpers/masumi"; +import type { PaymentPayload, PaymentRequired, PaymentRequirements } from "@x402/core/types"; +import { buildSignedTx } from "../helpers/buildSignedTx"; +import { + freshPreprodAddress, + NONCE_REF, + stubFacilitatorSigner as stubFacilitator, + TTL_SLOT, +} from "../helpers/stubs"; + +const PREPROD = CARDANO_PREPROD_CAIP2; + +/** Test-only facilitator with explicit volatile replay storage. */ +class ExactCardanoFacilitator extends ExactCardanoFacilitatorBase { + constructor(signer: FacilitatorCardanoSigner, config: ExactCardanoFacilitatorConfig = {}) { + super(signer, { inMemorySettlementStoreMaxEntries: 4096, ...config }); + } +} + +/** Test-only resource server with explicit volatile replay storage. */ +class ExactCardanoServer extends ExactCardanoServerBase { + constructor(config: ExactCardanoServerConfig = {}) { + super({ inMemoryStore: {}, ...config }); + } +} const TX_HASH = "a".repeat(64); @@ -37,11 +72,93 @@ const stubSigner: ClientCardanoSigner = { }), }; +async function serverReplayFixture(body: unknown = { job: 1 }) { + const payTo = await freshPreprodAddress(); + const built = await buildSignedTx({ + payTo, + asset: LOVELACE_ASSET, + amount: 2_000_000n, + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: PREPROD, + }); + const requirements = buildRequirements({ + network: PREPROD, + payTo, + asset: LOVELACE_ASSET, + amount: "2000000", + }); + const paymentPayload: PaymentPayload = { + x402Version: 2, + accepted: requirements, + payload: { transaction: built.transaction, nonce: built.nonce }, + }; + const transportContext = { + request: { + method: "POST", + adapter: { + getMethod: () => "POST", + getUrl: () => "https://example.com/jobs", + getHeader: (name: string) => + name === "content-type" + ? "application/json" + : name === "authorization" + ? "Bearer owner-token" + : undefined, + getBody: () => body, + }, + }, + }; + return { + context: { + paymentPayload, + requirements, + declaredExtensions: {}, + transportContext, + result: { isValid: true, payer: "addr_test1payer" }, + }, + transportContext, + }; +} + +async function attachReplayChallenge( + server: ExactCardanoServer, + fixture: Awaited>, + echoPaymentPayload = false, +): Promise { + const paymentRequiredResponse: PaymentRequired = { + x402Version: 2, + resource: { url: "https://example.com/jobs" }, + accepts: [fixture.context.requirements], + }; + await server.enrichPaymentRequiredResponse({ + requirements: [fixture.context.requirements], + requirement: fixture.context.requirements, + resourceInfo: paymentRequiredResponse.resource, + paymentRequiredResponse, + transportContext: fixture.transportContext, + ...(echoPaymentPayload ? { paymentPayload: fixture.context.paymentPayload } : {}), + }); + fixture.context.paymentPayload.extensions = paymentRequiredResponse.extensions; + const challenge = ( + paymentRequiredResponse.extensions?.cardanoReplayProtection as { + challenges?: Record; + } + )?.challenges; + const value = challenge && Object.values(challenge)[0]; + if (!value) throw new Error("test replay challenge was not issued"); + return value; +} + const stubFacilitatorSigner: FacilitatorCardanoSigner = { getAddresses: () => ["addr1qfacilitator00"], getUtxo: async () => ({ exists: true, address: "addr1qpayer00" }), + validatePhase1Transaction: async () => undefined, getCurrentSlot: async () => 100n, - submitTransaction: async () => ({ txHash: "deadbeef", status: "confirmed" }), + submitTransaction: async transaction => ({ + txHash: decodeCardanoTransaction(transaction).txHash, + status: "confirmed", + }), }; describe("ExactCardanoScheme client", () => { @@ -67,24 +184,120 @@ describe("ExactCardanoScheme client", () => { it("rejects invalid asset units", async () => { await expect( client.createPaymentPayload(2, buildRequirements({ asset: "not.a.unit" })), - ).rejects.toThrow(/Invalid Cardano asset unit/); + ).rejects.toThrow(/canonical lowercase form/); }); it("rejects non-numeric amounts", async () => { await expect( client.createPaymentPayload(2, buildRequirements({ amount: "10.5" })), - ).rejects.toThrow(/Amount must be a non-negative integer/); + ).rejects.toThrow(/positive canonical integer/); + }); + + it("rejects zero, leading-zero, and uppercase wire values", async () => { + await expect( + client.createPaymentPayload(2, buildRequirements({ amount: "0" })), + ).rejects.toThrow(/positive canonical integer/); + await expect( + client.createPaymentPayload(2, buildRequirements({ amount: "010000" })), + ).rejects.toThrow(/positive canonical integer/); + await expect( + client.createPaymentPayload( + 2, + buildRequirements({ asset: USDM_MAINNET_ASSET.toUpperCase() }), + ), + ).rejects.toThrow(/canonical lowercase form/); + }); + + it("rejects Masumi settlement fields returned for a default payment", async () => { + const c = new ExactCardanoClient({ + ...stubSigner, + buildAndSignPaymentTransaction: () => ({ + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + settlementLayer: "l1", + }), + }); + await expect(c.createPaymentPayload(2, buildRequirements())).rejects.toThrow( + /non-Masumi payment/, + ); }); it("returns a payload from the signer for valid requirements", async () => { const result = await client.createPaymentPayload(2, buildRequirements()); expect(result.x402Version).toBe(2); + // An absent `submissionPolicy` normalizes to `server`, and the payload + // records the mode the signer was asked to honour. expect(result.payload).toEqual({ transaction: "AAAA", nonce: `${TX_HASH}#0`, + submissionMode: "server", }); }); + it("selects the mode the server's submissionPolicy dictates", async () => { + const seen: string[] = []; + const recordingSigner: ClientCardanoSigner = { + getAddress: () => "addr1qxsomeaddress00", + buildAndSignPaymentTransaction: input => { + seen.push(input.submissionMode); + return { + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + submissionMode: input.submissionMode, + }; + }, + }; + const c = new ExactCardanoClient(recordingSigner); + await c.createPaymentPayload(2, buildRequirements({ extra: { submissionPolicy: "client" } })); + expect(seen).toEqual(["client"]); + + // `either` leaves the choice to the client's configured preference. + const preferring = new ExactCardanoClient(recordingSigner, "client"); + const result = await preferring.createPaymentPayload( + 2, + buildRequirements({ extra: { submissionPolicy: "either" } }), + ); + expect((result.payload as { submissionMode: string }).submissionMode).toBe("client"); + }); + + it("rejects requirements carrying an invalid policy", async () => { + await expect( + client.createPaymentPayload( + 2, + buildRequirements({ extra: { confirmationPolicy: { l1Confirmations: 99 } } }), + ), + ).rejects.toThrow(/invalid submission\/confirmation policy/); + }); + + it("rejects a signer that ignored client-submission mode", async () => { + const lyingSigner: ClientCardanoSigner = { + getAddress: () => "addr1qxsomeaddress00", + buildAndSignPaymentTransaction: () => ({ + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + submissionMode: "server" as const, + }), + }; + const c = new ExactCardanoClient(lyingSigner); + await expect( + c.createPaymentPayload(2, buildRequirements({ extra: { submissionPolicy: "client" } })), + ).rejects.toThrow(/honoured submissionMode server, expected client/); + }); + + it("rejects a signer that omits client-submission mode", async () => { + const omittingSigner: ClientCardanoSigner = { + getAddress: () => "addr1qxsomeaddress00", + buildAndSignPaymentTransaction: () => ({ + transaction: "AAAA", + nonce: `${TX_HASH}#0`, + }), + }; + const c = new ExactCardanoClient(omittingSigner); + await expect( + c.createPaymentPayload(2, buildRequirements({ extra: { submissionPolicy: "client" } })), + ).rejects.toThrow(/honoured submissionMode undefined, expected client/); + }); + it("rejects signer responses with invalid nonce", async () => { const badSigner: ClientCardanoSigner = { getAddress: () => "addr1q", @@ -98,6 +311,12 @@ describe("ExactCardanoScheme client", () => { }); describe("ExactCardanoScheme facilitator", () => { + it("requires replay persistence unless volatile storage is explicit", () => { + expect(() => new ExactCardanoFacilitatorBase(stubFacilitatorSigner)).toThrow( + /durable settlementStore/, + ); + }); + it("declares CAIP family and scheme identifier", () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); expect(facilitator.scheme).toBe("exact"); @@ -109,9 +328,59 @@ describe("ExactCardanoScheme facilitator", () => { expect(facilitator.getSigners(CARDANO_MAINNET_CAIP2)).toEqual(["addr1qfacilitator00"]); }); - it("returns undefined for getExtra (no metadata required by default)", () => { + it("advertises its capabilities via getExtra", () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); - expect(facilitator.getExtra(CARDANO_PREPROD_CAIP2)).toBeUndefined(); + expect(facilitator.getExtra(CARDANO_PREPROD_CAIP2)).toEqual({ + assetTransferMethods: ["default", "masumi", "script"], + // No Hydra client is configured, so only L1 is offered. + settlementLayers: ["l1"], + // This stub signer has no evidence hook, so client submission is not offered. + submissionModes: ["server"], + l1Confirmations: { + server: { minimum: 0, maximum: 0 }, + }, + }); + }); + + it("advertises client submission once it can authenticate evidence", () => { + const facilitator = new ExactCardanoFacilitator({ + ...stubFacilitatorSigner, + getTransactionEvidence: async () => ({ status: "confirmed" as const, confirmations: 3 }), + }); + const extra = facilitator.getExtra(CARDANO_PREPROD_CAIP2)!; + expect(extra.submissionModes).toEqual(["server", "client"]); + expect(extra.l1Confirmations).toEqual({ + server: { minimum: 0, maximum: 20 }, + client: { minimum: 0, maximum: 20 }, + }); + }); + + it("advertises mempool evidence when the operator enables it", () => { + const facilitator = new ExactCardanoFacilitator( + { + ...stubFacilitatorSigner, + getTransactionEvidence: async () => ({ status: "mempool" as const, confirmations: -1 }), + }, + { acceptMempool: true }, + ); + const extra = facilitator.getExtra(CARDANO_PREPROD_CAIP2)!; + expect(extra.l1Confirmations).toEqual({ + server: { minimum: -1, maximum: 20 }, + client: { minimum: -1, maximum: 20 }, + }); + }); + + it("does not advertise server submission without a complete phase-1 validator", () => { + const facilitator = new ExactCardanoFacilitator({ + ...stubFacilitatorSigner, + validatePhase1Transaction: undefined, + getTransactionEvidence: async () => ({ status: "confirmed" as const, confirmations: 3 }), + }); + const extra = facilitator.getExtra(CARDANO_PREPROD_CAIP2)!; + expect(extra.submissionModes).toEqual(["client"]); + expect(extra.l1Confirmations).toEqual({ + client: { minimum: 0, maximum: 20 }, + }); }); it("rejects payloads when networks differ", async () => { @@ -139,6 +408,21 @@ describe("ExactCardanoScheme facilitator", () => { expect(result.invalidReason).toBe("network_mismatch"); }); + it("rejects non-canonical requirements before decoding the transaction", async () => { + const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); + for (const reqs of [ + buildRequirements({ amount: "0" }), + buildRequirements({ amount: "010000" }), + buildRequirements({ asset: USDM_MAINNET_ASSET.toUpperCase() }), + ]) { + const result = await facilitator.verify( + { x402Version: 2, accepted: reqs, payload: { transaction: "AA", nonce: `${TX_HASH}#0` } }, + reqs, + ); + expect(result.invalidReason).toBe("invalid_exact_cardano_requirements"); + } + }); + it("rejects payloads with malformed nonce", async () => { const facilitator = new ExactCardanoFacilitator(stubFacilitatorSigner); const reqs = buildRequirements(); @@ -165,31 +449,24 @@ describe("ExactCardanoScheme facilitator", () => { expect(supportedCardanoNetworks()).toEqual(CARDANO_NETWORKS); }); - it("rejects script assetTransferMethod by default to avoid silent acceptance", async () => { - // The base implementation cannot reconstruct script addresses; integrators - // must override `runMethodSpecificChecks`. Until then, script payments are - // refused even when the funds and address match. + it("rejects a script payment whose payTo is not the declared script address", async () => { class TestFacilitator extends ExactCardanoFacilitator {} const facilitator = new TestFacilitator(stubFacilitatorSigner); - // We cannot easily exercise the full happy path without a CSL build, but - // we can confirm the method rejects by reaching into the protected hook. const result = await ( facilitator as unknown as { runMethodSpecificChecks: ( - extra: Record | undefined, requirements: PaymentRequirements, decoded: unknown, - payer: string, + context: unknown, ) => Promise<{ ok: true } | { ok: false; reason: string }>; } ).runMethodSpecificChecks( - { - assetTransferMethod: "script", - scriptHash: "deadbeef", - }, - buildRequirements({ payTo: RECIPIENT }), + buildRequirements({ + payTo: RECIPIENT, + extra: { assetTransferMethod: "script", scriptHash: "deadbeef" }, + }), { outputs: [] }, - "addr1qpayer00", + { payload: { transaction: "AA", nonce: `${TX_HASH}#0` }, payer: "addr1qpayer00" }, ); expect(result).toEqual({ ok: false, @@ -197,105 +474,711 @@ describe("ExactCardanoScheme facilitator", () => { }); }); - it("rejects a second concurrent settle as duplicate", async () => { + describe("settlement", () => { + // `settle()` re-derives its state from the real transaction, so these + // isolation tests need a decodable one. Verification itself is stubbed out + // by overriding verify(), which settle() still dispatches through. + let transaction: string; + let canonicalTxHash: string; + let reqs: PaymentRequirements; + + /** A facilitator whose verification always passes. */ class FakeOk extends ExactCardanoFacilitator { override async verify() { return { isValid: true, payer: "addr1qpayer00" }; } } - const facilitator = new FakeOk(stubFacilitatorSigner); - const reqs = buildRequirements(); - const payload = { + + /** + * Builds a payment payload around the shared fixture transaction. + * + * @param submissionMode - Optional payload submission mode. + * @returns The payment payload. + */ + const payloadFor = (submissionMode?: "server" | "client") => ({ x402Version: 2, accepted: reqs, - payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + payload: { transaction, nonce: NONCE_REF, ...(submissionMode ? { submissionMode } : {}) }, + }); + + beforeAll(async () => { + const built = await buildSignedTx({ + payTo: await freshPreprodAddress(), + asset: LOVELACE_ASSET, + amount: 2_000_000n, + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: PREPROD, + }); + transaction = built.transaction; + canonicalTxHash = decodeCardanoTransaction(transaction).txHash; + reqs = buildRequirements({ network: PREPROD, asset: LOVELACE_ASSET, amount: "2000000" }); + }, 60_000); + + it("rejects a submitter response for a different transaction id", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => ({ txHash: "b".repeat(64), status: "confirmed" }), + getTransactionEvidence: async () => ({ status: "unknown", confirmations: -2 }), + }), + ); + const result = await facilitator.settle(payloadFor(), reqs); + expect(result.success).toBe(false); + expect(result.errorReason).toBe("exact_cardano_settlement_failed"); + expect(result.errorMessage).toContain(`expected ${canonicalTxHash}`); + }); + + // The race the spec's mitigation targets: two callers reaching submission + // before either has landed. + it("rejects a concurrent second settle for the same transaction", async () => { + let release: () => void = () => {}; + let signalReachedSubmit: () => void = () => {}; + const gate = new Promise(resolve => { + release = resolve; + }); + // Resolves once the first call is genuinely mid-submission, so the second + // call races a claim that is in flight rather than one not yet taken. + const reachedSubmit = new Promise(resolve => { + signalReachedSubmit = resolve; + }); + let submits = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submits += 1; + signalReachedSubmit(); + await gate; + return { txHash: canonicalTxHash, status: "confirmed" }; + }, + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 1 }), + }), + ); + const first = facilitator.settle(payloadFor(), reqs); + await reachedSubmit; + + const second = await facilitator.settle(payloadFor(), reqs); + expect(second.success).toBe(false); + expect(second.errorReason).toBe("duplicate_settlement"); + + release(); + expect((await first).success).toBe(true); + // The duplicate never reached the node. + expect(submits).toBe(1); + }); + + it("coordinates settlement claims across facilitator instances", async () => { + let release: () => void = () => {}; + let signalReachedSubmit: () => void = () => {}; + const gate = new Promise(resolve => { + release = resolve; + }); + const reachedSubmit = new Promise(resolve => { + signalReachedSubmit = resolve; + }); + let submits = 0; + const signer = stubFacilitator({ + submitTransaction: async () => { + submits += 1; + signalReachedSubmit(); + await gate; + return { txHash: canonicalTxHash, status: "confirmed" }; + }, + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 1 }), + }); + const settlementStore = new InMemoryCardanoSettlementStore(); + const firstFacilitator = new FakeOk(signer, { settlementStore }); + const secondFacilitator = new FakeOk(signer, { settlementStore }); + + const first = firstFacilitator.settle(payloadFor(), reqs); + await reachedSubmit; + const duplicate = await secondFacilitator.settle(payloadFor(), reqs); + + expect(duplicate.success).toBe(false); + expect(duplicate.errorReason).toBe("duplicate_settlement"); + release(); + expect((await first).success).toBe(true); + expect(submits).toBe(1); + }); + + // A transaction that has not reached the required depth returns + // payment_pending; the spec REQUIRES the paid retry to resume observing it + // rather than be refused, or a fully paid payment could never be released. + it("resumes a pending settlement on retry without submitting again", async () => { + let submits = 0; + let confirmations = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submits += 1; + return { txHash: canonicalTxHash, status: "confirmed" }; + }, + getTransactionEvidence: async () => ({ status: "confirmed", confirmations }), + }), + { confirmationTimeoutMs: 1, confirmationPollMs: 1 }, + ); + const strict = buildRequirements({ + ...reqs, + extra: { confirmationPolicy: { l1Confirmations: 2 } }, + }); + + const pending = await facilitator.settle({ ...payloadFor(), accepted: strict }, strict); + expect(pending.success).toBe(false); + expect(pending.errorReason).toBe("payment_pending"); + + // The chain advances; the retry must now succeed. + confirmations = 2; + const retry = await facilitator.settle({ ...payloadFor(), accepted: strict }, strict); + expect(retry.success).toBe(true); + expect(retry.extra).toMatchObject({ confirmations: 2 }); + expect(submits).toBe(1); + }); + + // Most providers expose no mempool read, so a just-broadcast transaction is + // briefly indistinguishable from an unknown one. That is pending, not proof + // the transaction does not exist. + it("reports a just-submitted but not-yet-observable transaction as pending", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => ({ txHash: canonicalTxHash, status: "mempool" }), + getTransactionEvidence: async () => ({ status: "unknown", confirmations: -2 }), + }), + { confirmationTimeoutMs: 1, confirmationPollMs: 1 }, + ); + const settle = await facilitator.settle(payloadFor(), reqs); + expect(settle.success).toBe(false); + // Not `evidence_mismatch`: the node took it, we just cannot see it yet. + expect(settle.errorReason).toBe("exact_cardano_settlement_not_confirmed"); + expect(settle.extra).toMatchObject({ status: "mempool" }); + }); + + // A signer that broadcasts and then waits for inclusion throws on a + // confirmation timeout with the transaction already in flight. Releasing the + // claim there would make the retry rebroadcast a transaction that may + // already have landed — and typically fail on spent inputs, leaving the + // payer charged with no resource. + it("keeps the claim when submission throws after the transaction landed", async () => { + let submits = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submits += 1; + // Broadcast succeeded; the wait for confirmation did not. + throw new Error("timed out awaiting confirmation"); + }, + // The ledger nonetheless has it. + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 1 }), + }), + { confirmationTimeoutMs: 1, confirmationPollMs: 1 }, + ); + + // The throw is recovered from: the transaction is on-chain, so this + // settles rather than reporting a failed payment. + const first = await facilitator.settle(payloadFor(), reqs); + expect(first.success).toBe(true); + expect(first.extra).toMatchObject({ confirmations: 1 }); + + // And the retry resumes observation without a second broadcast. + const retry = await facilitator.settle(payloadFor(), reqs); + expect(retry.success).toBe(true); + expect(submits).toBe(1); + }); + + it("tombstones a transaction after definitive pre-ledger rejection", async () => { + let submits = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submits += 1; + throw new Error("BadInputsUTxO (input already spent)"); + }, + getTransactionEvidence: async () => ({ status: "unknown", confirmations: -2 }), + isDefinitiveSubmissionRejection: error => + error instanceof Error && error.message.includes("BadInputsUTxO"), + }), + ); + const failed = await facilitator.settle(payloadFor(), reqs); + expect(failed.success).toBe(false); + expect(failed.errorReason).toBe("exact_cardano_settlement_definitively_rejected"); + expect(failed.errorMessage).toContain("BadInputsUTxO"); + + // The handler already ran before settlement. Reusing or replacing this + // payment could bind that result to different bytes, so the quote is now + // terminal and the rejected transaction is never broadcast again. + const retry = await facilitator.settle(payloadFor(), reqs); + expect(retry.errorReason).toBe("exact_cardano_settlement_definitively_rejected"); + expect(submits).toBe(1); + }); + + it("retains the claim after an ambiguous submission failure", async () => { + let submits = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submits += 1; + throw new Error("provider connection closed"); + }, + getTransactionEvidence: async () => ({ status: "unknown", confirmations: -2 }), + }), + { confirmationTimeoutMs: 1, confirmationPollMs: 1 }, + ); + const first = await facilitator.settle(payloadFor(), reqs); + expect(first.success).toBe(false); + expect(first.transaction).toBe(decodeCardanoTransaction(transaction).txHash); + await facilitator.settle(payloadFor(), reqs); + expect(submits).toBe(1); + }); + + it("refuses a retry that flips the normalized submission mode", async () => { + const facilitator = new FakeOk(stubFacilitator()); + const either = buildRequirements({ ...reqs, extra: { submissionPolicy: "either" } }); + const first = await facilitator.settle({ ...payloadFor("server"), accepted: either }, either); + expect(first.success).toBe(true); + const flipped = await facilitator.settle( + { ...payloadFor("client"), accepted: either }, + either, + ); + expect(flipped.success).toBe(false); + expect(flipped.errorReason).toBe("invalid_exact_cardano_payload_submission_mode_mismatch"); + }); + + it("reports the strongest verified evidence in the response extra", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 4 }), + }), + ); + const settle = await facilitator.settle(payloadFor(), reqs); + expect(settle.success).toBe(true); + expect(settle.extra).toMatchObject({ + status: "confirmed", + submissionMode: "server", + confirmations: 4, + }); + }); + + it("reports payment_pending when evidence is below the confirmation policy", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 0 }), + }), + { confirmationTimeoutMs: 1, confirmationPollMs: 1 }, + ); + const strict = buildRequirements({ + ...reqs, + extra: { confirmationPolicy: { l1Confirmations: 3 } }, + }); + const settle = await facilitator.settle({ ...payloadFor(), accepted: strict }, strict); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("payment_pending"); + expect(settle.extra).toMatchObject({ status: "pending", confirmations: 0 }); + }); + + it("rejects mempool-only settlements when acceptMempool is disabled (default)", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => ({ txHash: canonicalTxHash, status: "mempool" }), + getTransactionEvidence: undefined, + }), + ); + const settle = await facilitator.settle(payloadFor(), reqs); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("exact_cardano_settlement_not_confirmed"); + }); + + it("accepts mempool-only settlements when acceptMempool is true and the policy allows -1", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => ({ txHash: canonicalTxHash, status: "mempool" }), + getTransactionEvidence: undefined, + }), + { acceptMempool: true }, + ); + const lenient = buildRequirements({ + ...reqs, + extra: { confirmationPolicy: { l1Confirmations: -1 } }, + }); + const settle = await facilitator.settle({ ...payloadFor(), accepted: lenient }, lenient); + expect(settle.success).toBe(true); + expect(settle.extra).toMatchObject({ status: "mempool", confirmations: -1 }); + }); + + it("surfaces the underlying error message when submission throws", async () => { + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + throw new Error("BadInputsUTxO (input already spent)"); + }, + }), + ); + const settle = await facilitator.settle(payloadFor(), reqs); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("exact_cardano_settlement_failed"); + expect(settle.errorMessage).toContain("BadInputsUTxO"); + }); + + it("never submits in client mode, settling from authenticated evidence alone", async () => { + let submitted = 0; + const facilitator = new FakeOk( + stubFacilitator({ + submitTransaction: async () => { + submitted += 1; + return { txHash: "abc", status: "confirmed" }; + }, + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 2 }), + }), + ); + const clientReqs = buildRequirements({ + ...reqs, + extra: { submissionPolicy: "client" }, + }); + const settle = await facilitator.settle( + { ...payloadFor("client"), accepted: clientReqs }, + clientReqs, + ); + expect(settle.success).toBe(true); + expect(submitted).toBe(0); + expect(settle.extra).toMatchObject({ submissionMode: "client", confirmations: 2 }); + }); + + it("refuses a payload whose mode the policy does not allow", async () => { + const facilitator = new FakeOk(stubFacilitator()); + const serverOnly = buildRequirements({ ...reqs, extra: { submissionPolicy: "server" } }); + const settle = await facilitator.settle( + { ...payloadFor("client"), accepted: serverOnly }, + serverOnly, + ); + expect(settle.success).toBe(false); + expect(settle.errorReason).toBe("invalid_exact_cardano_payload_submission_mode_mismatch"); + }); + }); +}); + +describe("ExactCardanoScheme server", () => { + it("requires replay persistence unless volatile storage is explicit", () => { + expect(() => new ExactCardanoServerBase()).toThrow(/durable operationStore/); + }); + + it("awaits asynchronous bodies and prefers exact raw bytes", async () => { + const asyncBody = await serverReplayFixture(); + const asyncAdapter = asyncBody.transportContext.request.adapter; + asyncAdapter.getBody = async () => ({ job: 1 }); + const asyncServer = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + expect(await asyncServer.schemeHooks.onAfterVerify!(asyncBody.context)).toBeUndefined(); + + const rawBody = await serverReplayFixture(); + const rawAdapter = rawBody.transportContext.request.adapter as typeof asyncAdapter & { + getRawBody(): Promise; + }; + rawAdapter.getBody = () => { + throw new Error("parsed body must not be read when raw bytes are available"); }; - const first = await facilitator.settle(payload, reqs); - const second = await facilitator.settle(payload, reqs); - expect(first.success).toBe(true); - expect(second.success).toBe(false); - expect(second.errorReason).toBe("duplicate_settlement"); + rawAdapter.getRawBody = async () => new TextEncoder().encode('{ "job": 1 }'); + const rawServer = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + expect(await rawServer.schemeHooks.onAfterVerify!(rawBody.context)).toBeUndefined(); }); - it("rejects mempool-only settlements when acceptMempool is disabled (default)", async () => { - const mempoolSigner: FacilitatorCardanoSigner = { - ...stubFacilitatorSigner, - submitTransaction: async () => ({ txHash: "abc", status: "mempool" }), + it("claims the payment before the handler and replays its stored result", async () => { + const payTo = await freshPreprodAddress(); + const built = await buildSignedTx({ + payTo, + asset: LOVELACE_ASSET, + amount: 2_000_000n, + nonceUtxoRef: NONCE_REF, + ttlSlot: TTL_SLOT, + network: PREPROD, + }); + const requirements = buildRequirements({ + network: PREPROD, + payTo, + asset: LOVELACE_ASSET, + amount: "2000000", + }); + const paymentPayload = { + x402Version: 2, + accepted: requirements, + payload: { transaction: built.transaction, nonce: built.nonce }, }; - // Bypass verify() by stubbing it via subclass for this isolation test. - class FakeOk extends ExactCardanoFacilitator { - override async verify() { - return { isValid: true, payer: "addr1qpayer00" }; - } - } - const facilitator = new FakeOk(mempoolSigner); - const reqs = buildRequirements(); - const settle = await facilitator.settle( - { - x402Version: 2, - accepted: reqs, - payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + const transport = (body: unknown, headers: Record = {}) => ({ + request: { + method: "POST", + adapter: { + getMethod: () => "POST", + getUrl: () => "https://example.com/jobs", + getHeader: (name: string) => headers[name.toLowerCase()], + getBody: () => body, + }, }, - reqs, - ); - expect(settle.success).toBe(false); - expect(settle.errorReason).toBe("exact_cardano_settlement_not_confirmed"); - expect(settle.transaction).toBe("abc"); - }); + }); + const hookContext = (transportContext: ReturnType) => ({ + paymentPayload, + requirements, + declaredExtensions: {}, + transportContext, + result: { isValid: true, payer: "addr_test1payer" }, + }); - it("accepts mempool-only settlements when acceptMempool is true", async () => { - const mempoolSigner: FacilitatorCardanoSigner = { - ...stubFacilitatorSigner, - submitTransaction: async () => ({ txHash: "abc", status: "mempool" }), + const operationStore = new InMemoryCardanoOperationStore(); + const requestBinding = ({ getHeader }: { getHeader(name: string): string | undefined }) => + getHeader("authorization") ?? ""; + const server = new ExactCardanoServer({ operationStore, requestBinding }); + const peerServer = new ExactCardanoServer({ operationStore, requestBinding }); + const hooks = server.schemeHooks; + const peerHooks = peerServer.schemeHooks; + const requesterHeaders = { + "content-type": "application/json", + authorization: "Bearer owner-token", }; - class FakeOk extends ExactCardanoFacilitator { - override async verify() { - return { isValid: true, payer: "addr1qpayer00" }; - } - } - const facilitator = new FakeOk(mempoolSigner, { acceptMempool: true }); - const reqs = buildRequirements(); - const settle = await facilitator.settle( - { - x402Version: 2, - accepted: reqs, - payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + const owner = transport({ job: 1 }, requesterHeaders); + expect( + await hooks.onAfterVerify!({ + ...hookContext(owner), + result: { isValid: false, invalidReason: "input_unavailable", payer: "" }, + }), + ).toBeUndefined(); + expect(await hooks.onAfterVerify!(hookContext(owner))).toBeUndefined(); + + const concurrent = transport({ job: 1 }, requesterHeaders); + expect(await peerHooks.onAfterVerify!(hookContext(concurrent))).toMatchObject({ + abort: true, + reason: "duplicate_settlement", + }); + // Canceling the rejected duplicate must not release the original claim. + await peerHooks.onVerifiedPaymentCanceled!({ + ...hookContext(concurrent), + reason: "after_verify_aborted", + }); + expect( + await peerHooks.onAfterVerify!(hookContext(transport({ job: 1 }, requesterHeaders))), + ).toMatchObject({ abort: true }); + + await hooks.onAfterSettle!({ + ...hookContext({ + ...owner, + responseBody: Buffer.from('{"jobId":"job-1"}'), + responseHeaders: { + "content-type": "application/json", + "x-job": "job-1", + "set-cookie": "session=private", + "www-authenticate": "Bearer realm=private", + }, + responseStatus: 201, + }), + result: { + success: false, + transaction: decodeCardanoTransaction(built.transaction).txHash, + network: PREPROD, + errorReason: "payment_pending", }, - reqs, + }); + + const retry = await peerHooks.onAfterVerify!( + hookContext(transport({ job: 1 }, requesterHeaders)), ); - expect(settle.success).toBe(true); - expect((settle.extra as { status?: string } | undefined)?.status).toBe("mempool"); + expect(retry).toEqual({ + skipHandler: true, + response: { + status: 201, + contentType: "application/json", + headers: { "x-job": "job-1" }, + body: Buffer.from('{"jobId":"job-1"}'), + isRaw: true, + }, + }); + expect( + await hooks.onAfterVerify!( + hookContext( + transport({ job: 1 }, { ...requesterHeaders, authorization: "Bearer other-token" }), + ), + ), + ).toMatchObject({ + abort: true, + reason: "payment_replay_conflict", + status: 409, + }); + expect( + await hooks.onAfterVerify!(hookContext(transport({ job: 2 }, requesterHeaders))), + ).toMatchObject({ + abort: true, + reason: "payment_replay_conflict", + status: 409, + }); }); - it("surfaces the underlying error message when submission throws", async () => { - const throwingSigner: FacilitatorCardanoSigner = { - ...stubFacilitatorSigner, - submitTransaction: async () => { - throw new Error("BadInputsUTxO (input already spent)"); + it("replays an anonymous result only with the original 402 challenge", async () => { + const fixture = await serverReplayFixture(); + const adapter = fixture.transportContext.request.adapter; + adapter.getHeader = name => (name === "content-type" ? "application/json" : undefined); + const server = new ExactCardanoServer(); + const originalChallenge = await attachReplayChallenge(server, fixture); + + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toBeUndefined(); + await server.schemeHooks.onAfterSettle!({ + ...fixture.context, + transportContext: { + ...fixture.transportContext, + responseBody: Buffer.from('{"jobId":"job-1"}'), + responseHeaders: { "content-type": "application/json" }, + responseStatus: 200, }, + result: { success: true, transaction: "abc", network: PREPROD }, + }); + + const retry = await server.schemeHooks.onAfterVerify!(fixture.context); + expect(retry).toMatchObject({ + skipHandler: true, + response: { status: 200, body: Buffer.from('{"jobId":"job-1"}'), isRaw: true }, + }); + + const attackerChallenge = await attachReplayChallenge(server, fixture); + expect(attackerChallenge).not.toBe(originalChallenge); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_binding_required", + status: 403, + }); + }); + + it("does not reflect an invented challenge into a later 402 response", async () => { + const fixture = await serverReplayFixture(); + const server = new ExactCardanoServer(); + await attachReplayChallenge(server, fixture); + const replayProtection = fixture.context.paymentPayload.extensions?.cardanoReplayProtection as { + challenges: Record; }; - class FakeOk extends ExactCardanoFacilitator { - override async verify() { - return { isValid: true, payer: "addr1qpayer00" }; - } - } - const facilitator = new FakeOk(throwingSigner); - const reqs = buildRequirements(); - const settle = await facilitator.settle( - { - x402Version: 2, - accepted: reqs, - payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + const requirementKey = Object.keys(replayProtection.challenges)[0]!; + const invented = "f".repeat(64); + replayProtection.challenges[requirementKey] = invented; + + const returned = await attachReplayChallenge(server, fixture, true); + expect(returned).not.toBe(invented); + }); + + it("does not treat arbitrary authorization headers as authenticated request binding", async () => { + const fixture = await serverReplayFixture(); + fixture.context.paymentPayload.payload.submissionMode = "client"; + + const operationStore = new InMemoryCardanoOperationStore(); + const server = new ExactCardanoServer({ operationStore }); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_binding_required", + status: 403, + }); + + await attachReplayChallenge(server, fixture); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_binding_required", + status: 403, + }); + + const authenticatedServer = new ExactCardanoServer({ + operationStore, + requestBinding: () => "validated-owner", + }); + await attachReplayChallenge(authenticatedServer, fixture); + expect(await authenticatedServer.schemeHooks.onAfterVerify!(fixture.context)).toBeUndefined(); + }); + + it("retains an ambiguous tombstone when a handler throws or fails", async () => { + const fixture = await serverReplayFixture(); + const server = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toBeUndefined(); + await server.schemeHooks.onVerifiedPaymentCanceled!({ + ...fixture.context, + reason: "handler_threw", + }); + + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_outcome_ambiguous", + status: 409, + }); + }); + + it("marks the operation ambiguous when no handler response bytes are available", async () => { + const fixture = await serverReplayFixture(); + const server = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toBeUndefined(); + await server.schemeHooks.onAfterSettle!({ + ...fixture.context, + result: { success: true, transaction: "abc", network: PREPROD }, + }); + + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_outcome_ambiguous", + status: 409, + }); + }); + + it("rejects replay protection when hooks have no stable request adapter", async () => { + const fixture = await serverReplayFixture(); + const server = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + const context = { + ...fixture.context, + transportContext: { request: { method: "POST" } }, + }; + + expect(await server.schemeHooks.onAfterVerify!(context)).toMatchObject({ + abort: true, + reason: "payment_replay_store_unavailable", + status: 503, + }); + }); + + it("makes a definitive settlement rejection terminal for the protected operation", async () => { + const fixture = await serverReplayFixture(); + const server = new ExactCardanoServer({ requestBinding: () => "test-requester" }); + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toBeUndefined(); + + await server.schemeHooks.onSettleFailure!({ + ...fixture.context, + transportContext: { + ...fixture.transportContext, + responseBody: Buffer.from('{"jobId":"job-1"}'), + responseHeaders: { "content-type": "application/json" }, + responseStatus: 200, }, - reqs, - ); - expect(settle.success).toBe(false); - expect(settle.errorReason).toBe("exact_cardano_settlement_failed"); - expect(settle.errorMessage).toContain("BadInputsUTxO"); + result: { + success: false, + transaction: "abc", + network: PREPROD, + errorReason: "exact_cardano_settlement_definitively_rejected", + }, + }); + + expect(await server.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_outcome_ambiguous", + status: 409, + }); + }); + + it("fails closed when replay persistence or body canonicalization fails", async () => { + const fixture = await serverReplayFixture(new Map([["job", 1]])); + const invalidBodyServer = new ExactCardanoServer(); + expect(await invalidBodyServer.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_store_unavailable", + status: 503, + }); + + const unavailableStore = new InMemoryCardanoOperationStore(); + unavailableStore.claim = async () => { + throw new Error("database unavailable"); + }; + const unavailableServer = new ExactCardanoServer({ + operationStore: unavailableStore, + requestBinding: () => "test-requester", + }); + expect(await unavailableServer.schemeHooks.onAfterVerify!(fixture.context)).toMatchObject({ + abort: true, + reason: "payment_replay_store_unavailable", + status: 503, + }); }); -}); -describe("ExactCardanoScheme server", () => { it("parses Money strings to USDM atomic units", async () => { const server = new ExactCardanoServer(); const result = await server.parsePrice("$1.50", CARDANO_MAINNET_CAIP2); @@ -313,6 +1196,40 @@ describe("ExactCardanoScheme server", () => { expect(result.extra?.tier).toBe("premium"); }); + it("rejects non-canonical AssetAmount values before issuing requirements", async () => { + const server = new ExactCardanoServer(); + await expect( + server.parsePrice({ amount: "0", asset: LOVELACE_ASSET }, CARDANO_MAINNET_CAIP2), + ).rejects.toThrow(/positive canonical integer/); + await expect( + server.parsePrice({ amount: "001", asset: LOVELACE_ASSET }, CARDANO_MAINNET_CAIP2), + ).rejects.toThrow(/positive canonical integer/); + await expect( + server.parsePrice( + { amount: "1", asset: USDM_MAINNET_ASSET.toUpperCase() }, + CARDANO_MAINNET_CAIP2, + ), + ).rejects.toThrow(/canonical lowercase Cardano form/); + }); + + it("rejects non-canonical custom money parser results", async () => { + const server = new ExactCardanoServer(); + server.registerMoneyParser(async () => ({ + amount: "01", + asset: USDM_MAINNET_ASSET, + })); + await expect(server.parsePrice("1", CARDANO_MAINNET_CAIP2)).rejects.toThrow( + /positive canonical integer/, + ); + }); + + it("rejects a Money value that rounds to zero atomic units", async () => { + const server = new ExactCardanoServer(); + await expect(server.parsePrice("$0.0000001", CARDANO_MAINNET_CAIP2)).rejects.toThrow( + /too small to represent/, + ); + }); + it("supports MoneyParser chaining", async () => { const server = new ExactCardanoServer(); server.registerMoneyParser(async amount => @@ -327,7 +1244,11 @@ describe("ExactCardanoScheme server", () => { expect(small.amount).toBe("1000000"); }); - it("enhancePaymentRequirements merges supported kind extra", async () => { + // `/supported` extra is capability advertisement, not payload semantics. + // Merging it into the requirements would put `assetTransferMethods`, + // `settlementLayers` and friends inside `extra` — and the Masumi `extra` is a + // CLOSED object, so every Masumi 402 would be invalid on arrival. + it("enhancePaymentRequirements leaves the requirements' extra untouched", async () => { const server = new ExactCardanoServer(); const baseRequirements = buildRequirements({ extra: { foo: "bar" } }); const enhanced = await server.enhancePaymentRequirements( @@ -336,10 +1257,175 @@ describe("ExactCardanoScheme server", () => { x402Version: 2, scheme: "exact", network: CARDANO_MAINNET_CAIP2, - extra: { policy: "default" }, + extra: { + assetTransferMethods: ["default", "masumi", "script"], + settlementLayers: ["l1"], + submissionModes: ["server", "client"], + l1Confirmations: { + server: { minimum: 0, maximum: 20 }, + client: { minimum: 0, maximum: 20 }, + }, + }, + }, + [], + ); + expect(enhanced.extra).toEqual({ foo: "bar" }); + }); + + // A facilitator that publishes an `extra` has claimed to describe itself, so a + // capability this scheme selects and cannot find there is a rejection — not + // silent permission to serve a 402 nobody can settle. + it("rejects a half-filled facilitator capability advertisement", async () => { + const server = new ExactCardanoServer(); + await expect( + server.enhancePaymentRequirements( + buildRequirements(), + { + x402Version: 2, + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + extra: { assetTransferMethods: ["default"], settlementLayers: ["l1"] }, + }, + [], + ), + ).rejects.toThrow(/did not advertise submissionModes/); + }); + + // `auto` lets the buyer pick, but this scheme can only authenticate L1 — a + // Hydra payload is refused in verifyMasumiLock — so a Hydra-only facilitator + // must not satisfy it. + it("rejects Masumi auto settlement against a Hydra-only facilitator", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "5000000", + payByTimeMs: BigInt(Date.now() + 5 * 60 * 1000), + settlementPolicy: "auto", + confirmationPolicy: { l1Confirmations: 0 }, + }); + const server = new ExactCardanoServer(); + const capabilities = (layers: string[]) => ({ + x402Version: 2 as const, + scheme: "exact", + network: CARDANO_PREPROD_CAIP2, + extra: { + assetTransferMethods: ["default", "masumi"], + settlementLayers: layers, + submissionModes: ["server"], + l1Confirmations: { server: { minimum: 0, maximum: 20 } }, + }, + }); + + await expect( + server.enhancePaymentRequirements(requirements, capabilities(["hydra"]), []), + ).rejects.toThrow(/does not support Masumi auto settlement/); + await expect( + server.enhancePaymentRequirements(requirements, capabilities(["l1"]), []), + ).resolves.toBeDefined(); + }); + + it("rejects an explicit Masumi hydra policy the facilitator does not advertise", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "5000000", + payByTimeMs: BigInt(Date.now() + 5 * 60 * 1000), + settlementPolicy: "hydra", + confirmationPolicy: { l1Confirmations: 0 }, + }); + const server = new ExactCardanoServer(); + await expect( + server.enhancePaymentRequirements( + requirements, + { + x402Version: 2, + scheme: "exact", + network: CARDANO_PREPROD_CAIP2, + extra: { + assetTransferMethods: ["default", "masumi"], + settlementLayers: ["l1"], + submissionModes: ["server"], + l1Confirmations: { server: { minimum: 0, maximum: 20 } }, + }, + }, + [], + ), + ).rejects.toThrow(/does not support Masumi hydra settlement/); + }); + + it("accepts requirements when the facilitator advertises no capabilities at all", async () => { + const server = new ExactCardanoServer(); + const enhanced = await server.enhancePaymentRequirements( + buildRequirements({ extra: { foo: "bar" } }), + { x402Version: 2, scheme: "exact", network: CARDANO_MAINNET_CAIP2 }, + [], + ); + expect(enhanced.extra).toEqual({ foo: "bar" }); + }); + + it("rejects requirements whose submission mode is not advertised", async () => { + const server = new ExactCardanoServer(); + await expect( + server.enhancePaymentRequirements( + buildRequirements(), + { + x402Version: 2, + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + extra: { + assetTransferMethods: ["default"], + settlementLayers: ["l1"], + submissionModes: ["client"], + l1Confirmations: { client: { minimum: 0, maximum: 20 } }, + }, + }, + [], + ), + ).rejects.toThrow(/does not support server submission/); + }); + + it("rejects requirements outside the advertised confirmation range", async () => { + const server = new ExactCardanoServer(); + await expect( + server.enhancePaymentRequirements( + buildRequirements({ + extra: { submissionPolicy: "server", confirmationPolicy: { l1Confirmations: 1 } }, + }), + { + x402Version: 2, + scheme: "exact", + network: CARDANO_MAINNET_CAIP2, + extra: { + assetTransferMethods: ["default"], + settlementLayers: ["l1"], + submissionModes: ["server"], + l1Confirmations: { server: { minimum: 0, maximum: 0 } }, + }, + }, + [], + ), + ).rejects.toThrow(/confirmation range does not include 1/); + }); + + it("keeps an issued Masumi extra schema-valid through enhancement", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "5000000", + payByTimeMs: 1_785_756_000_000n, + confirmationPolicy: { l1Confirmations: 0 }, + }); + const server = new ExactCardanoServer(); + const enhanced = await server.enhancePaymentRequirements( + requirements, + { + x402Version: 2, + scheme: "exact", + network: CARDANO_PREPROD_CAIP2, + extra: new ExactCardanoFacilitator(stubFacilitator()).getExtra(CARDANO_PREPROD_CAIP2), }, [], ); - expect(enhanced.extra).toEqual({ policy: "default", foo: "bar" }); + expect(validateMasumiExtra(enhanced.extra, CARDANO_PREPROD_CAIP2).ok).toBe(true); }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/scriptAddress.test.ts b/typescript/packages/mechanisms/cardano/test/unit/scriptAddress.test.ts index be0b0e127f..444d2e3a8b 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/scriptAddress.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/scriptAddress.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { scriptAddressMatches } from "../../src/exact/facilitator/scriptAddress"; +import { + deriveScriptHashHex, + scriptAddressMatches, +} from "../../src/exact/facilitator/scriptAddress"; +import { MAX_CARDANO_SCRIPT_BYTES, MAX_CARDANO_SCRIPT_PARAMETERS } from "../../src/limits"; import type { CardanoExtraScript } from "../../src/types"; import { freshPreprodAddress, MINIMAL_PLUTUS_V3, scriptAddressFor } from "../helpers/stubs"; @@ -54,4 +58,50 @@ describe("scriptAddressMatches", () => { const extra = { assetTransferMethod: "script" } as CardanoExtraScript; expect(scriptAddressMatches(extra, addr)).toBe(false); }); + + it("rejects oversized scripts and parameter sets before applying them", () => { + expect( + scriptAddressMatches( + { + assetTransferMethod: "script", + script: { type: "plutusV3", code: "00".repeat(MAX_CARDANO_SCRIPT_BYTES + 1) }, + }, + addr, + ), + ).toBe(false); + const parameters = Object.fromEntries( + Array.from({ length: MAX_CARDANO_SCRIPT_PARAMETERS + 1 }, (_, index) => [ + `p${index}`, + { type: "integer" as const, value: "1" }, + ]), + ); + expect( + scriptAddressMatches( + { assetTransferMethod: "script", script: { type: "plutusV3", code: CODE }, parameters }, + addr, + ), + ).toBe(false); + }); + + it("rejects unsafe or non-canonical integer parameters", () => { + for (const value of [1.5, Number.MAX_SAFE_INTEGER + 1, "01", "1e3", "+1", "-0"]) { + expect(() => + deriveScriptHashHex({ + assetTransferMethod: "script", + script: { type: "plutusV3", code: CODE }, + parameters: { p1: { type: "integer", value } }, + }), + ).toThrow(/integer parameter/); + } + }); + + it("accepts canonical signed integer parameters", () => { + expect( + deriveScriptHashHex({ + assetTransferMethod: "script", + script: { type: "plutusV3", code: CODE }, + parameters: { p1: { type: "integer", value: "-42" } }, + }), + ).toMatch(/^[0-9a-f]{56}$/); + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/scriptDatum.test.ts b/typescript/packages/mechanisms/cardano/test/unit/scriptDatum.test.ts index 46348639a8..82bd3e3ad7 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/scriptDatum.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/scriptDatum.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { CardanoExtraScript } from "../../src/types"; import { buildScriptDatumInline } from "../../src/exact/script/datum"; +import { MAX_CARDANO_DATUM_BYTES } from "../../src/limits"; /** Builds a script `extra` block with the supplied optional datum. */ function scriptExtra(datum?: string): CardanoExtraScript { @@ -31,4 +32,10 @@ describe("buildScriptDatumInline", () => { it("throws on a datum that is not valid CBOR hex", () => { expect(() => buildScriptDatumInline(scriptExtra("zznothex"))).toThrow(/not valid CBOR hex/); }); + + it("rejects an oversized datum before decoding CBOR", () => { + expect(() => + buildScriptDatumInline(scriptExtra("00".repeat(MAX_CARDANO_DATUM_BYTES + 1))), + ).toThrow(/non-empty CBOR hex/); + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/security.test.ts b/typescript/packages/mechanisms/cardano/test/unit/security.test.ts index 5ab730a973..03f43bacab 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/security.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/security.test.ts @@ -13,7 +13,10 @@ vi.mock("../../src/utils", async original => { }); import { decodeCardanoTransaction } from "../../src/utils"; -import { ExactCardanoScheme as ExactCardanoFacilitator } from "../../src/exact/facilitator/scheme"; +import { + ExactCardanoScheme as ExactCardanoFacilitatorBase, + type ExactCardanoFacilitatorConfig, +} from "../../src/exact/facilitator/scheme"; import { CARDANO_MAINNET_CAIP2, CARDANO_MAINNET_CIP34, @@ -25,6 +28,13 @@ import type { PaymentRequirements } from "@x402/core/types"; const TX_HASH = "a".repeat(64); const RECIPIENT = "addr1qxytestrecipientaddress00"; +/** Test-only facilitator with explicit volatile replay storage. */ +class ExactCardanoFacilitator extends ExactCardanoFacilitatorBase { + constructor(signer: FacilitatorCardanoSigner, config: ExactCardanoFacilitatorConfig = {}) { + super(signer, { inMemorySettlementStoreMaxEntries: 4096, ...config }); + } +} + const buildRequirements = (extra: Record = {}): PaymentRequirements => ({ scheme: "exact", network: CARDANO_MAINNET_CAIP2, @@ -32,25 +42,121 @@ const buildRequirements = (extra: Record = {}): PaymentRequirem amount: "10000", payTo: RECIPIENT, maxTimeoutSeconds: 600, - extra, + extra: { confirmationPolicy: { l1Confirmations: 0 }, ...extra }, }); const stubSigner: FacilitatorCardanoSigner = { getAddresses: () => ["addr1qfacilitator00"], - getUtxo: async () => ({ exists: true, address: "addr1qpayer00" }), + getUtxo: async () => ({ + exists: true, + address: "addr1qpayer00", + coin: 0n, + assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, + paymentKeyHash: "payer", + }), getCurrentSlot: async () => 100n, + validatePhase1Transaction: async () => undefined, submitTransaction: async () => ({ txHash: "deadbeef", status: "confirmed" }), + getTransactionEvidence: async () => ({ status: "unknown", confirmations: -2 }), }; describe("Cardano facilitator security", () => { + const decodedPayment = () => ({ + txHash: "abc", + networkId: 1, + ttlSlot: undefined, + validityStartSlot: undefined, + inputs: [`${TX_HASH}#0`], + fee: 0n, + unsupportedPhase1Operations: [], + outputs: [ + { + address: RECIPIENT, + coin: 0n, + assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, + }, + ], + vkeyHashes: ["payer"], + isValid: true, + vkeyWitnessCount: 1, + scriptWitnessCount: 0, + redeemerCount: 0, + signaturesValid: true, + }); + + it("rejects excessive input fan-out before any provider lookup", async () => { + const getUtxo = vi.fn(stubSigner.getUtxo); + const inputs = [ + `${TX_HASH}#0`, + ...Array.from( + { length: 256 }, + (_, index) => `${(index + 1).toString(16).padStart(64, "0")}#0`, + ), + ]; + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce({ ...decodedPayment(), inputs }); + + const requirements = buildRequirements(); + const result = await new ExactCardanoFacilitator({ ...stubSigner, getUtxo }).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_phase1_invalid"); + expect(getUtxo).not.toHaveBeenCalled(); + }); + + it("limits concurrent provider lookups for transaction inputs", async () => { + let active = 0; + let maximumActive = 0; + const getUtxo = vi.fn(async () => { + active++; + maximumActive = Math.max(maximumActive, active); + await new Promise(resolve => setTimeout(resolve, 1)); + active--; + return { + exists: true, + address: "addr1qpayer00", + coin: 0n, + assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, + paymentKeyHash: "payer", + }; + }); + const inputs = [ + `${TX_HASH}#0`, + ...Array.from( + { length: 19 }, + (_, index) => `${(index + 1).toString(16).padStart(64, "0")}#0`, + ), + ]; + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce({ ...decodedPayment(), inputs }); + + const requirements = buildRequirements(); + const result = await new ExactCardanoFacilitator({ ...stubSigner, getUtxo }).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + + expect(result.isValid).toBe(true); + expect(getUtxo).toHaveBeenCalledTimes(20); + expect(maximumActive).toBeLessThanOrEqual(8); + }); + it("reads assetTransferMethod from canonical requirements, not client-echoed accepted", async () => { let capturedExtra: Record | undefined; class CaptureFacilitator extends ExactCardanoFacilitator { protected override async runMethodSpecificChecks( - extra: Record | undefined, + requirements: PaymentRequirements, ): Promise<{ ok: true } | { ok: false; reason: string }> { - capturedExtra = extra ? { ...extra } : undefined; + capturedExtra = requirements.extra ? { ...requirements.extra } : undefined; return { ok: true }; } } @@ -61,6 +167,8 @@ describe("Cardano facilitator security", () => { ttlSlot: undefined, validityStartSlot: undefined, inputs: [`${TX_HASH}#0`], + fee: 0n, + unsupportedPhase1Operations: [], outputs: [ { address: RECIPIENT, @@ -68,8 +176,11 @@ describe("Cardano facilitator security", () => { assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, }, ], + vkeyHashes: ["payer"], + isValid: true, vkeyWitnessCount: 1, scriptWitnessCount: 0, + redeemerCount: 0, signaturesValid: true, }); @@ -87,6 +198,7 @@ describe("Cardano facilitator security", () => { expect(result.isValid).toBe(true); expect(capturedExtra).toEqual({ assetTransferMethod: "script", + confirmationPolicy: { l1Confirmations: 0 }, scriptHash: "deadbeef", }); }); @@ -98,6 +210,8 @@ describe("Cardano facilitator security", () => { ttlSlot: undefined, validityStartSlot: undefined, inputs: [`${TX_HASH}#0`], + fee: 0n, + unsupportedPhase1Operations: [], outputs: [ { address: RECIPIENT, @@ -105,8 +219,11 @@ describe("Cardano facilitator security", () => { assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, }, ], + vkeyHashes: ["payer"], + isValid: true, vkeyWitnessCount: 1, scriptWitnessCount: 0, + redeemerCount: 0, signaturesValid: true, }); @@ -122,4 +239,156 @@ describe("Cardano facilitator security", () => { expect(result.isValid).toBe(true); expect(result.payer).toBe("addr1qpayer00"); }); + + it("rejects Masumi settlement fields on the default method", async () => { + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decodedPayment()); + const requirements = buildRequirements(); + const result = await new ExactCardanoFacilitator(stubSigner).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0`, settlementLayer: "l1" }, + }, + requirements, + ); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_settlement_layer_mismatch"); + }); + + it("rejects a confirmation depth it cannot authenticate", async () => { + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decodedPayment()); + const withoutEvidence = { ...stubSigner, getTransactionEvidence: undefined }; + const requirements = buildRequirements({ confirmationPolicy: { l1Confirmations: 1 } }); + const result = await new ExactCardanoFacilitator(withoutEvidence).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + expect(result.invalidReason).toBe("exact_cardano_facilitator_evidence_unavailable"); + }); + + it("rejects server submission when a complete phase-1 validator is unavailable", async () => { + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decodedPayment()); + const withoutPhase1 = { ...stubSigner, validatePhase1Transaction: undefined }; + const requirements = buildRequirements(); + const result = await new ExactCardanoFacilitator(withoutPhase1).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_phase1_invalid"); + expect(result.invalidMessage).toContain("requires a complete Cardano phase-1 validator"); + }); + + it("surfaces a complete phase-1 validator rejection", async () => { + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decodedPayment()); + const invalidSigner: FacilitatorCardanoSigner = { + ...stubSigner, + validatePhase1Transaction: async () => { + throw new Error("ValueNotConservedUTxO"); + }, + }; + const requirements = buildRequirements(); + const result = await new ExactCardanoFacilitator(invalidSigner).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + expect(result.invalidReason).toBe("invalid_exact_cardano_payload_phase1_invalid"); + expect(result.invalidMessage).toContain("ValueNotConservedUTxO"); + }); + + it("uses an explicit full phase-1 validator for non-payment transaction shapes", async () => { + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce({ + ...decodedPayment(), + unsupportedPhase1Operations: ["mint"], + vkeyHashes: ["unrelated"], + }); + const validatePhase1Transaction = vi.fn(async () => undefined); + const advancedSigner: FacilitatorCardanoSigner = { + ...stubSigner, + validatePhase1Transaction, + }; + const requirements = buildRequirements({ confirmationPolicy: { l1Confirmations: 0 } }); + const result = await new ExactCardanoFacilitator(advancedSigner).verify( + { + x402Version: 2, + accepted: requirements, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0` }, + }, + requirements, + ); + expect(result.isValid).toBe(true); + expect(validatePhase1Transaction).toHaveBeenCalledWith("AAAA", CARDANO_MAINNET_CAIP2); + }); + + // `is_valid` sits outside the transaction body, so it is not covered by the + // transaction id: a client can broadcast the failing (`is_valid = false`) form + // and hand the facilitator the identical payload claiming `true`. Evidence + // keyed by that id would then point at a transaction that created no outputs. + // Only a Plutus-script transaction can be phase-2 invalid at all. + it("refuses a client-submitted payment that runs Plutus scripts", async () => { + const decoded = { + txHash: "abc", + networkId: 1, + ttlSlot: undefined, + validityStartSlot: undefined, + inputs: [`${TX_HASH}#0`], + fee: 0n, + unsupportedPhase1Operations: [], + outputs: [ + { + address: RECIPIENT, + coin: 0n, + assets: { [USDM_MAINNET_ASSET.toLowerCase()]: 10_000n }, + }, + ], + vkeyHashes: ["payer"], + isValid: true, + vkeyWitnessCount: 1, + scriptWitnessCount: 1, + redeemerCount: 1, + signaturesValid: true, + }; + const clientReqs = buildRequirements({ submissionPolicy: "client" }); + const evidenceSigner: FacilitatorCardanoSigner = { + ...stubSigner, + // Even a provider that vouches for the transaction cannot rescue it. + getTransactionEvidence: async () => ({ status: "confirmed", confirmations: 5 }), + }; + const payload = { + x402Version: 2, + accepted: clientReqs, + payload: { transaction: "AAAA", nonce: `${TX_HASH}#0`, submissionMode: "client" }, + }; + + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decoded); + const refused = await new ExactCardanoFacilitator(evidenceSigner).verify(payload, clientReqs); + expect(refused.isValid).toBe(false); + expect(refused.invalidReason).toBe("invalid_exact_cardano_payload_phase2_invalid"); + + // The same payment without redeemers is fine. + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce({ + ...decoded, + scriptWitnessCount: 0, + redeemerCount: 0, + }); + const allowed = await new ExactCardanoFacilitator(evidenceSigner).verify(payload, clientReqs); + expect(allowed.isValid).toBe(true); + + // An operator with a provider that verifies `valid_contract` can opt in. + vi.mocked(decodeCardanoTransaction).mockReturnValueOnce(decoded); + const optedIn = await new ExactCardanoFacilitator(evidenceSigner, { + allowClientScriptExecution: true, + }).verify(payload, clientReqs); + expect(optedIn.isValid).toBe(true); + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/signer.test.ts b/typescript/packages/mechanisms/cardano/test/unit/signer.test.ts index ed2f91522e..c5c8307ebd 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/signer.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/signer.test.ts @@ -1,11 +1,22 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { preprod, PrivateKey } from "@evolution-sdk/evolution"; -import { toFacilitatorCardanoSigner } from "../../src/signer"; +import { + blockfrostQueries, + toClientCardanoSigner, + toFacilitatorCardanoSigner, + withCardanoProviderTimeout, +} from "../../src/signer"; import { CARDANO_MAINNET_CAIP2, CARDANO_PREPROD_CAIP2, CARDANO_PREPROD_CIP34, + LOVELACE_ASSET, } from "../../src/constants"; +import { MASUMI_DEFAULT_DEPLOYMENT } from "../../src/exact/masumi/blueprint"; +import { MASUMI_MAX_DEADLINE_HORIZON_MS } from "../../src/exact/masumi/constants"; +import { verifyMasumiAuthorization } from "../../src/exact/masumi/verify"; +import type { CardanoExtraMasumi } from "../../src/types"; +import { issueMasumiRequirements } from "../helpers/masumi"; const makeSigner = (): ReturnType => toFacilitatorCardanoSigner({ @@ -16,6 +27,23 @@ const makeSigner = (): ReturnType => }); describe("toFacilitatorCardanoSigner", () => { + it("exposes and network-guards an injected complete phase-1 validator", async () => { + const calls: Array<{ transaction: string; network: string }> = []; + const signer = toFacilitatorCardanoSigner({ + network: CARDANO_PREPROD_CAIP2, + provider: { blockfrost: { baseUrl: "http://offline.invalid" } }, + validatePhase1Transaction: async (transaction, network) => { + calls.push({ transaction, network }); + }, + }); + + await signer.validatePhase1Transaction!("AAAA", CARDANO_PREPROD_CIP34); + expect(calls).toEqual([{ transaction: "AAAA", network: CARDANO_PREPROD_CIP34 }]); + await expect(signer.validatePhase1Transaction!("AAAA", CARDANO_MAINNET_CAIP2)).rejects.toThrow( + /configured for cardano:preprod/, + ); + }); + it("derives the current slot from the chain slot config (no network)", async () => { const signer = makeSigner(); const slot = await signer.getCurrentSlot(CARDANO_PREPROD_CAIP2); @@ -69,4 +97,371 @@ describe("toFacilitatorCardanoSigner", () => { }); expect(providerOnly.getAddresses()).toEqual([]); }); + + it("attaches a bounded timeout to direct Blockfrost evidence requests", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 404 })); + vi.stubGlobal("fetch", fetchMock); + try { + const signer = toFacilitatorCardanoSigner({ + network: CARDANO_PREPROD_CAIP2, + provider: { + blockfrost: { baseUrl: "https://cardano-preprod.blockfrost.io/api/v0" }, + requestTimeoutMs: 25, + }, + }); + await expect( + signer.getTransactionEvidence!("a".repeat(64), CARDANO_PREPROD_CAIP2), + ).resolves.toEqual({ status: "unknown", confirmations: -2 }); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("propagates provider failures while resolving a spent UTxO", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 503 })); + vi.stubGlobal("fetch", fetchMock); + try { + const queries = blockfrostQueries({ + blockfrost: { baseUrl: "https://cardano-preprod.blockfrost.io/api/v0" }, + }); + await expect(queries.spentUtxoAddress("a".repeat(64), 0)).rejects.toThrow( + /Blockfrost \/txs\/.+\/utxos failed: 503/, + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("rejects invalid provider timeout configuration", () => { + expect(() => + toFacilitatorCardanoSigner({ + network: CARDANO_PREPROD_CAIP2, + provider: { + blockfrost: { baseUrl: "http://offline.invalid" }, + requestTimeoutMs: 0, + }, + }), + ).toThrow(/requestTimeoutMs/); + expect(() => + toFacilitatorCardanoSigner({ + network: CARDANO_PREPROD_CAIP2, + provider: { koios: { baseUrl: "http://offline.invalid" }, requestTimeoutMs: 0 }, + }), + ).toThrow(/requestTimeoutMs/); + }); + + it("bounds provider promises by the configured deadline", async () => { + vi.useFakeTimers(); + try { + const pending = withCardanoProviderTimeout( + new Promise(() => undefined), + 25, + "testOperation", + ); + const rejection = expect(pending).rejects.toThrow(/testOperation timed out after 25ms/); + await vi.advanceTimersByTimeAsync(25); + await rejection; + } finally { + vi.useRealTimers(); + } + }); +}); + +// The client is about to move real value, and in client-submission mode it +// broadcasts before any facilitator sees the payment. It therefore has to verify +// the seller authorization itself rather than trust the 402 — these all fail +// before any provider call, so no network is involved. +describe("client-side Masumi authorization", () => { + const PAY_BY_TIME = BigInt(Date.now() + 5 * 60 * 1000); + + const clientSigner = ( + config: Partial[0]> = {}, + ): ReturnType => + toClientCardanoSigner({ + mnemonic: PrivateKey.generateMnemonic(), + network: CARDANO_PREPROD_CAIP2, + provider: { blockfrost: { baseUrl: "http://offline.invalid" } }, + ...config, + }); + + /** + * Builds the signer input for an issued Masumi 402. + * + * @param requirements - The issued requirements. + * @param payTo - Optional override for the escrow address. + * @returns The signer input. + */ + const signInput = ( + requirements: Awaited>["requirements"], + payTo = requirements.payTo, + ) => ({ + network: CARDANO_PREPROD_CAIP2, + payTo, + asset: requirements.asset, + amount: requirements.amount, + maxTimeoutSeconds: requirements.maxTimeoutSeconds, + extra: requirements.extra, + submissionMode: "server" as const, + }); + + it("refuses a 402 that redirects payTo away from the derived escrow", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + }); + const attackerAddress = + "addr_test1qzdjjcstngx8yneqv4d2phmz35ytkyxk4aa09rfexu7kj3evleltf708u3qyrn29sudutxqqy0vx5f3lv73dtewsdras79zz7d"; + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements, attackerAddress)), + ).rejects.toThrow(/seller authorization failed/); + }); + + it("refuses a 402 whose seller signature does not cover the terms", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + }); + // Raise the price after the seller signed it. + const tampered = { ...requirements, amount: "60000000" }; + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(tampered)), + ).rejects.toThrow(/masumi_seller_signature/); + }); + + it("refuses a non-canonical deployment unless the application approves it", async () => { + const custom = { ...MASUMI_DEFAULT_DEPLOYMENT, cooldownPeriod: "999999" }; + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + deployment: custom, + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/custom deployment requires explicit application approval/); + let inspectedDeployment: unknown; + await expect( + clientSigner({ + validateCustomMasumiDeployment: claim => { + inspectedDeployment = claim; + return claim.deployment.cooldownPeriod === custom.cooldownPeriod; + }, + }).buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/Blockfrost/); + expect(inspectedDeployment).toMatchObject({ + network: CARDANO_PREPROD_CAIP2, + payTo: requirements.payTo, + deployment: custom, + }); + + await expect( + clientSigner({ validateCustomMasumiDeployment: () => false }).buildAndSignPaymentTransaction( + signInput(requirements), + ), + ).rejects.toThrow(/custom deployment was not approved/); + }); + + it("refuses a registry claim it cannot independently validate", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/masumi_agent_identifier/); + }); + + it("refuses invalid signed deadlines before wallet or provider access", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + submitResultTimeMs: PAY_BY_TIME + 1n, + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/deadline intervals below the minimum/); + }); + + // The buyer cannot recover the payment or the collateral before + // `submit_result_time`, so a 402 naming a deadline a year out would freeze the + // wallet's funds for a year while satisfying every minimum-gap rule. + it("refuses deadlines beyond the accepted horizon before provider access", async () => { + const payByTime = BigInt(Date.now() + 5 * 60 * 1000); + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: payByTime, + externalDisputeUnlockTimeMs: payByTime + BigInt(400 * 24 * 60 * 60 * 1000), + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/deadlines extend beyond the accepted horizon/); + + // An operator that genuinely accepts a long settlement window can raise it. + await expect( + clientSigner({ + masumiMaxDeadlineHorizonMs: BigInt(500 * 24 * 60 * 60 * 1000), + }).buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/Blockfrost/); + + // The horizon is buyer policy, so a verifier does not impose one. If it did, + // it could reject exactly the lock a client with a raised horizon already + // made — stranding the funds the check exists to protect. + const extra = requirements.extra as unknown as CardanoExtraMasumi; + await expect(verifyMasumiAuthorization(extra, requirements)).resolves.toMatchObject({ + ok: true, + }); + await expect( + verifyMasumiAuthorization(extra, requirements, { + maxDeadlineHorizonMs: MASUMI_MAX_DEADLINE_HORIZON_MS, + }), + ).resolves.toMatchObject({ ok: false, detail: "deadlines extend beyond the accepted horizon" }); + }); + + it("refuses a payByTime outside the x402 timeout before provider access", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + maxTimeoutSeconds: 60, + payByTimeMs: BigInt(Date.now() + 5 * 60 * 1000), + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/payByTime exceeds maxTimeoutSeconds/); + }); + + it("refuses a script-credential buyer return address before wallet access", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + }); + await expect( + clientSigner({ + masumiBuyerInput: () => ({ buyerReturnAddress: requirements.payTo }), + }).buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/buyer return address must be a key-credential address/); + }); + + it("awaits registry validation with the protected resource", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + agentIdentifier: `67ab0c92c4ac1610895a1c965ee50aba41a8f1513b15240723b3bd0b${"01".repeat(8)}`, + }); + const resource = { url: "https://agent.example.com/weather" }; + await expect( + clientSigner({ + validateMasumiRegistryClaim: async () => true, + }).buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/protected resource/); + await expect( + clientSigner({ + validateMasumiRegistryClaim: async claim => claim.resource.url === resource.url, + }).buildAndSignPaymentTransaction({ ...signInput(requirements), resource }), + ).rejects.toThrow(/Blockfrost/); + }); + + // The issuer MAY omit `content` for a part derived from the buyer's own + // request bytes — the buyer recomputes that digest from what it actually sent. + // A client that skips the check instead lets a seller invent the digest and + // bind the escrow to a request that was never made. + it("refuses a commitment part whose content it cannot verify", async () => { + const body = { days: 3, units: "metric" }; + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + parts: [{ name: "body", canonicalization: "jcs", content: body }], + }); + // The issuer withholds the content it committed to. + const extra = requirements.extra as unknown as { inputCommitment: { parts: unknown[] } }; + const withheld = { + ...requirements, + extra: { + ...requirements.extra, + inputCommitment: { + ...(requirements.extra as unknown as { inputCommitment: object }).inputCommitment, + parts: extra.inputCommitment.parts.map(p => + Object.fromEntries( + Object.entries(p as Record).filter(([k]) => k !== "content"), + ), + ), + }, + }, + } as typeof requirements; + + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(withheld)), + ).rejects.toThrow(/carries no content to verify its digest against/); + + // Supplying the buyer's own request content makes it verifiable again. + await expect( + clientSigner({ masumiRequestContent: { body } }).buildAndSignPaymentTransaction( + signInput(withheld), + ), + ).rejects.toThrow(/Blockfrost/); // got past authorization, failed at the offline provider + }); + + it("refuses buyer content that does not match the committed digest", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + parts: [{ name: "body", canonicalization: "jcs", content: { days: 3, units: "metric" } }], + }); + const extra = requirements.extra as unknown as { inputCommitment: { parts: unknown[] } }; + const withheld = { + ...requirements, + extra: { + ...requirements.extra, + inputCommitment: { + ...(requirements.extra as unknown as { inputCommitment: object }).inputCommitment, + parts: extra.inputCommitment.parts.map(p => + Object.fromEntries( + Object.entries(p as Record).filter(([k]) => k !== "content"), + ), + ), + }, + }, + } as typeof requirements; + + // The buyer actually sent something else, so the digest must not recompute. + await expect( + clientSigner({ + masumiRequestContent: { body: { days: 4, units: "metric" } }, + }).buildAndSignPaymentTransaction(signInput(withheld)), + ).rejects.toThrow(/masumi_commitment/); + }); + + it("refuses Hydra terms it cannot settle", async () => { + const { requirements } = await issueMasumiRequirements({ + network: CARDANO_PREPROD_CAIP2, + asset: LOVELACE_ASSET, + amount: "50000000", + payByTimeMs: PAY_BY_TIME, + settlementPolicy: "hydra", + }); + await expect( + clientSigner().buildAndSignPaymentTransaction(signInput(requirements)), + ).rejects.toThrow(/Hydra settlement/); + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/types.test.ts b/typescript/packages/mechanisms/cardano/test/unit/types.test.ts index ee69df430a..0924603806 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/types.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/types.test.ts @@ -12,21 +12,58 @@ describe("Cardano Types", () => { expect(extra).toBeDefined(); }); + it("accepts the shared submission and confirmation policies", () => { + const extra: CardanoExtraDefault = { + submissionPolicy: "either", + confirmationPolicy: { l1Confirmations: 3 }, + }; + expect(extra.submissionPolicy).toBe("either"); + }); + it("accepts a Masumi extra with all required fields", () => { const extra: CardanoExtraMasumi = { assetTransferMethod: "masumi", - identifierFromPurchaser: "aabbaabb11221122aabb", - sellerVkey: "deadbeef", - paymentType: "Web3CardanoV1", - blockchainIdentifier: "blockchain_identifier", - payByTime: "1713626260", - submitResultTime: "1713636260", - unlockTime: "1713636260", - externalDisputeUnlockTime: "1713636260", - agentIdentifier: "agent_identifier", - inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + submissionPolicy: "server", + confirmationPolicy: { l1Confirmations: 1 }, + inputCommitment: { + version: "1", + algorithm: "sha256", + parts: [ + { + name: "body", + canonicalization: "jcs", + mediaType: "application/json", + content: { days: 3 }, + digest: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + ], + digest: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }, + terms: { + version: "1", + paymentType: "Web3CardanoV2", + sellerAddress: "addr_test1q...", + sellerNonce: "ab".repeat(32), + buyerNonce: "", + inputHash: "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + payByTime: "1713626260000", + submitResultTime: "1713636260000", + unlockTime: "1713640260000", + externalDisputeUnlockTime: "1713644260000", + settlementPolicy: "auto", + }, + referenceKey: "a10101", + referenceSignature: "55".repeat(16), + blockchainIdentifier: "deadbeef", + deployment: { + requiredAdmins: "2", + adminVkeys: ["fc16a1fcf309aed03ec18bb2176f5ea29acea70bb79145ebaffa8e75"], + cooldownPeriod: "420000", + }, }; expect(extra.assetTransferMethod).toBe("masumi"); + // `agentIdentifier` is optional; omitting it means the seller is unregistered. + expect(extra.terms.agentIdentifier).toBeUndefined(); }); it("accepts a Script extra with parameters", () => { @@ -48,4 +85,15 @@ describe("Cardano Types", () => { expect(payload.transaction).toBe("AAA="); expect(payload.nonce.endsWith("#0")).toBe(true); }); + + it("accepts the optional settlement fields on a payload", () => { + const payload: ExactCardanoPayload = { + transaction: "AAA=", + nonce: `${"a".repeat(64)}#0`, + submissionMode: "client", + settlementLayer: "hydra", + headId: "a".repeat(56), + }; + expect(payload.settlementLayer).toBe("hydra"); + }); }); diff --git a/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts b/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts index abac7c2d7f..30293c51e4 100644 --- a/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts +++ b/typescript/packages/mechanisms/cardano/test/unit/utils.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { decodeCardanoPayload, + decodeCardanoTransactionBytes, minUtxoLovelace, parseAssetUnit, parseUtxoRef, } from "../../src/utils"; +import { MAX_CARDANO_TRANSACTION_BYTES } from "../../src/limits"; const ASSET = "c48cbb3d5e57ed56e276bc45f99ab39abe94e6cd7ac39fb402da47ad.0014df105553444d"; const TX_HASH = "a".repeat(64); @@ -38,4 +40,17 @@ describe("Cardano Utils", () => { expect(minUtxoLovelace(0, 4310n)).toBe(160n * 4310n); expect(minUtxoLovelace(64, 4310n)).toBe(224n * 4310n); }); + + it("strictly bounds and canonicalizes transaction base64", () => { + expect(decodeCardanoTransactionBytes(Buffer.from([1, 2, 3]).toString("base64"))).toEqual( + Uint8Array.from([1, 2, 3]), + ); + expect(() => decodeCardanoTransactionBytes("AQID!ignored")).toThrow(/canonical padded base64/); + expect(() => + decodeCardanoPayload({ + transaction: "A".repeat(Math.ceil(MAX_CARDANO_TRANSACTION_BYTES / 3) * 4 + 4), + nonce: `${TX_HASH}#0`, + }), + ).toThrow(/decode limit/); + }); }); diff --git a/typescript/packages/mechanisms/cardano/vitest.config.ts b/typescript/packages/mechanisms/cardano/vitest.config.ts index a03dc8bc65..1bf3fec52d 100644 --- a/typescript/packages/mechanisms/cardano/vitest.config.ts +++ b/typescript/packages/mechanisms/cardano/vitest.config.ts @@ -6,6 +6,11 @@ export default defineConfig(({ mode }) => ({ test: { env: loadEnv(mode, process.cwd(), ""), exclude: ["**/node_modules/**", "**/dist/**", "**/test/integrations/**"], + // Deriving the Masumi escrow address applies parameters to a ~20k-character + // compiled validator. The result is memoized per parameterization, but each + // test file runs in its own worker and so pays that cost once — several + // seconds on a CI runner, past vitest's 5s default. + testTimeout: 30_000, }, plugins: [tsconfigPaths({ projects: ["."] })], })); diff --git a/typescript/pnpm-lock.yaml b/typescript/pnpm-lock.yaml index 24271e1114..760760a358 100644 --- a/typescript/pnpm-lock.yaml +++ b/typescript/pnpm-lock.yaml @@ -6,6 +6,10 @@ settings: overrides: cssstyle: 5.3.6 + '@effect/platform-node>ws': 8.21.0 + '@effect/platform-node-shared>ws': 8.21.0 + '@parcel/watcher>picomatch': 4.0.4 + undici: 7.29.0 packageExtensionsChecksum: sha256-9aEVigHYMKY79/D4KG0UAC0zjd2npsOZ16El58+Uvfk= @@ -542,7 +546,7 @@ importers: version: 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) '@solana/transaction-confirmation': specifier: ^2.1.1 - version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-standard-features': specifier: ^1.3.0 version: 1.3.0 @@ -666,19 +670,19 @@ importers: version: 1.2.6 '@solana-program/compute-budget': specifier: ^0.11.0 - version: 0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + version: 0.11.0(@solana/kit@5.0.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/token': specifier: ^0.9.0 - version: 0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + version: 0.9.0(@solana/kit@5.0.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/token-2022': specifier: ^0.6.1 - version: 0.6.1(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) + version: 0.6.1(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)) '@solana/kit': specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-confirmation': specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/wallet-standard-features': specifier: ^1.3.0 version: 1.3.0 @@ -973,7 +977,7 @@ importers: version: 1.36.1(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) '@solana/kit': specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) hono: specifier: ^4.7.1 version: 4.10.7 @@ -1040,7 +1044,7 @@ importers: version: 1.36.1(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) '@solana/kit': specifier: ^5.0.0 - version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + version: 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) next: specifier: '>=15.5.9 || >=16.0.10' version: 16.0.10(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -1282,9 +1286,15 @@ importers: '@evolution-sdk/evolution': specifier: ^0.5.9 version: 0.5.11(@effect/cluster@0.58.2(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/workflow@0.18.2(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(effect@3.21.4))(@effect/rpc@0.75.1(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/sql@0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(bufferutil@4.0.9)(utf-8-validate@5.0.10) + '@noble/hashes': + specifier: ^2.2.0 + version: 2.2.0 '@x402/core': specifier: workspace:~ version: link:../../core + lz-string: + specifier: ^1.5.0 + version: 1.5.0 devDependencies: '@eslint/js': specifier: ^9.24.0 @@ -7665,6 +7675,10 @@ packages: lute-connect@1.7.0: resolution: {integrity: sha512-/eXb2/c/xltKyVEVWchd1QZB6F0fvgXwVIqXDQWeJ9unPo0kMMbtuLkeb1v4Kr1lffxX8uGnb+8kAMYjczUASg==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.18: resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} @@ -8243,6 +8257,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} @@ -9367,8 +9385,8 @@ packages: undici-types@7.22.0: resolution: {integrity: sha512-RKZvifiL60xdsIuC80UY0dq8Z7DbJUV8/l2hOVbyZAxBzEeQU4Z58+4ZzJ6WN2Lidi9KzT5EbiGX+PI/UGYuRw==} - undici@7.28.0: - resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} engines: {node: '>=20.18.1'} universalify@0.1.2: @@ -9786,6 +9804,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -9901,7 +9931,7 @@ snapshots: '@algorandfoundation/xhd-wallet-api': 2.0.0-canary.1 '@noble/curves': 2.0.1 '@noble/ed25519': 3.0.0 - '@noble/hashes': 2.0.1 + '@noble/hashes': 2.2.0 algorand-msgpack: 1.1.0 buffer: 6.0.3 dotenv: 16.6.1 @@ -9914,7 +9944,7 @@ snapshots: dependencies: '@noble/ciphers': 2.1.1 '@noble/curves': 2.0.1 - '@noble/hashes': 2.0.1 + '@noble/hashes': 2.2.0 ajv: 8.17.1 algo-msgpack-with-bigint: 2.1.1 bn.js: 5.2.3 @@ -10527,7 +10557,7 @@ snapshots: '@parcel/watcher': 2.5.6 effect: 3.21.4 multipasta: 0.2.7 - ws: 8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -10541,8 +10571,8 @@ snapshots: '@effect/sql': 0.51.1(@effect/experimental@0.60.0(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4))(@effect/platform@0.96.2(effect@3.21.4))(effect@3.21.4) effect: 3.21.4 mime: 3.0.0 - undici: 7.28.0 - ws: 8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + undici: 7.29.0 + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -11798,7 +11828,7 @@ snapshots: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.3 + picomatch: 4.0.4 optionalDependencies: '@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6 @@ -12640,9 +12670,9 @@ snapshots: '@socket.io/component-emitter@3.1.2': {} - '@solana-program/compute-budget@0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/compute-budget@0.11.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 5.0.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/compute-budget@0.11.0(@solana/kit@6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10))': dependencies: @@ -12657,9 +12687,9 @@ snapshots: '@solana/kit': 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) '@solana/sysvars': 6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana-program/token-2022@0.6.1(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': + '@solana-program/token-2022@0.6.1(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': dependencies: - '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/sysvars': 6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana-program/token-2022@0.6.1(@solana/kit@6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10))(@solana/sysvars@6.1.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2))': @@ -12671,9 +12701,9 @@ snapshots: dependencies: '@solana/kit': 6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10) - '@solana-program/token@0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + '@solana-program/token@0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: - '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/kit': 5.0.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/token@0.9.0(@solana/kit@6.1.0(bufferutil@4.0.9)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(utf-8-validate@5.0.10))': dependencies: @@ -13147,7 +13177,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/accounts': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -13161,11 +13191,11 @@ snapshots: '@solana/rpc': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/rpc-parsed-types': 5.0.0(typescript@5.9.2) '@solana/rpc-spec-types': 5.0.0(typescript@5.9.2) - '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/signers': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/sysvars': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/transaction-confirmation': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/transaction-confirmation': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) typescript: 5.9.2 @@ -13577,23 +13607,23 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@2.3.0(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.2) '@solana/functional': 2.3.0(typescript@5.9.2) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.2) '@solana/subscribable': 2.3.0(typescript@5.9.2) typescript: 5.9.2 - ws: 8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) - '@solana/rpc-subscriptions-channel-websocket@5.0.0(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions-channel-websocket@5.0.0(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 5.0.0(typescript@5.9.2) '@solana/functional': 5.0.0(typescript@5.9.2) '@solana/rpc-subscriptions-spec': 5.0.0(typescript@5.9.2) '@solana/subscribable': 5.0.0(typescript@5.9.2) typescript: 5.9.2 - ws: 8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + ws: 8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) '@solana/rpc-subscriptions-channel-websocket@5.1.0(typescript@5.9.2)(ws@7.5.10(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: @@ -13651,7 +13681,7 @@ snapshots: optionalDependencies: typescript: 5.9.2 - '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 2.3.0(typescript@5.9.2) '@solana/fast-stable-stringify': 2.3.0(typescript@5.9.2) @@ -13659,7 +13689,7 @@ snapshots: '@solana/promises': 2.3.0(typescript@5.9.2) '@solana/rpc-spec-types': 2.3.0(typescript@5.9.2) '@solana/rpc-subscriptions-api': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 2.3.0(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 2.3.0(typescript@5.9.2) '@solana/rpc-transformers': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -13669,7 +13699,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/rpc-subscriptions@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/rpc-subscriptions@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/errors': 5.0.0(typescript@5.9.2) '@solana/fast-stable-stringify': 5.0.0(typescript@5.9.2) @@ -13677,7 +13707,7 @@ snapshots: '@solana/promises': 5.0.0(typescript@5.9.2) '@solana/rpc-spec-types': 5.0.0(typescript@5.9.2) '@solana/rpc-subscriptions-api': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/rpc-subscriptions-channel-websocket': 5.0.0(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions-channel-websocket': 5.0.0(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-subscriptions-spec': 5.0.0(typescript@5.9.2) '@solana/rpc-transformers': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -14041,7 +14071,7 @@ snapshots: transitivePeerDependencies: - fastestsmallesttextencoderdecoder - '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/codecs-strings': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -14049,7 +14079,7 @@ snapshots: '@solana/keys': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/promises': 2.3.0(typescript@5.9.2) '@solana/rpc': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/transaction-messages': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/transactions': 2.3.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -14058,7 +14088,7 @@ snapshots: - fastestsmallesttextencoderdecoder - ws - '@solana/transaction-confirmation@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': + '@solana/transaction-confirmation@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10))': dependencies: '@solana/addresses': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/codecs-strings': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -14066,7 +14096,7 @@ snapshots: '@solana/keys': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/promises': 5.0.0(typescript@5.9.2) '@solana/rpc': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) - '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.20.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana/rpc-subscriptions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2)(ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)) '@solana/rpc-types': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/transaction-messages': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) '@solana/transactions': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.2) @@ -18603,6 +18633,8 @@ snapshots: lute-connect@1.7.0: {} + lz-string@1.5.0: {} + magic-string@0.30.18: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -19338,6 +19370,8 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pify@3.0.0: {} pify@4.0.1: {} @@ -20736,7 +20770,7 @@ snapshots: undici-types@7.22.0: {} - undici@7.28.0: {} + undici@7.29.0: {} universalify@0.1.2: {} @@ -21315,6 +21349,11 @@ snapshots: bufferutil: 4.0.9 utf-8-validate: 5.0.10 + ws@8.21.0(bufferutil@4.0.9)(utf-8-validate@5.0.10): + optionalDependencies: + bufferutil: 4.0.9 + utf-8-validate: 5.0.10 + xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} diff --git a/typescript/pnpm-workspace.yaml b/typescript/pnpm-workspace.yaml index 0426ba731d..7835da30e3 100644 --- a/typescript/pnpm-workspace.yaml +++ b/typescript/pnpm-workspace.yaml @@ -9,6 +9,10 @@ packages: - site overrides: cssstyle: 5.3.6 + "@effect/platform-node>ws": 8.21.0 + "@effect/platform-node-shared>ws": 8.21.0 + "@parcel/watcher>picomatch": 4.0.4 + undici: 7.29.0 packageExtensions: # @keetanetwork/anchor imports tslib at runtime but does not declare it. "@keetanetwork/anchor@*":