diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/inference-facilitator.iml b/.idea/inference-facilitator.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/inference-facilitator.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2696d33 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/all_networks.ts b/all_networks.ts index fea3f74..2515ec6 100644 --- a/all_networks.ts +++ b/all_networks.ts @@ -43,7 +43,11 @@ import { type PaymentSettlementJobData, type SettlementApiJobResponse, } from "./all_networks_types_helpers.js"; -import { closeInferenceUsageTracker, getInferenceUsageStats } from "./all_networks_usage.js"; +import { + closeInferenceUsageTracker, + getInferenceUsageStats, + recordInferenceUsage, +} from "./all_networks_usage.js"; import { type PaymentPayload, type PaymentRequirements, @@ -188,32 +192,44 @@ async function toJobResponse( return response; } -async function enqueuePaymentSettlementJob(args: { - paymentPayload: PaymentPayload; - paymentRequirements: PaymentRequirements; - usageMetadata?: InferenceUsageMetadata; -}): Promise { - const paymentJobId = `payment-${randomUUID()}`; - const paymentJob = await paymentQueue.add( - "payment-settlement", - { - paymentPayload: args.paymentPayload, - paymentRequirements: args.paymentRequirements, - usageMetadata: args.usageMetadata, - }, - { - jobId: paymentJobId, - }, - ); - - console.log("[api] Payment settlement job enqueued", { - jobId: paymentJobId, - hasUsageMetadata: Boolean(args.usageMetadata), - ...summarizePaymentRequirements(args.paymentRequirements), - ...summarizePaymentPayload(args.paymentPayload), - }); +function paymentMetricTags(paymentRequirements: PaymentRequirements): string[] { + const tagValue = (value: string | undefined, fallback = "unknown") => { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return fallback; + } + return normalized.replace(/[^a-z0-9_.:-]+/g, "_").slice(0, 100) || fallback; + }; + return [ + "worker:payment", + `network:${tagValue(paymentRequirements.network)}`, + `asset:${tagValue(paymentRequirements.asset)}`, + `scheme:${tagValue(paymentRequirements.scheme)}`, + ]; +} - return toJobResponse(PAYMENT_QUEUE_NAME, paymentJob); +function recordPaymentSettlementMetrics( + paymentRequirements: PaymentRequirements, + result: { success: boolean; errorReason?: string; amount?: string }, +): void { + const tags = paymentMetricTags(paymentRequirements); + if (result.success) { + incrementMetric("payment.settled.count", tags); + const amount = Number(paymentRequirements.amount); + if (Number.isFinite(amount)) { + incrementMetric("payment.settled.amount", tags, amount); + } + return; + } + const reason = + typeof result.errorReason === "string" + ? result.errorReason.split(":")[0].replace(/[^a-z0-9_.:-]+/gi, "_").toLowerCase() + : "unknown"; + const failureTags = [...tags, `reason:${reason || "unknown"}`]; + incrementMetric("payment.settlement.failed.count", failureTags); + if (reason.startsWith("invalid_exact_evm")) { + incrementMetric("payment.settlement.invalid_evm.count", failureTags); + } } async function enqueueDataSettlementJob(args: { @@ -344,12 +360,30 @@ app.post("/settle", async (req, res) => { }); debugLog("[api][debug] /settle request body", req.body); - const paymentJob = await enqueuePaymentSettlementJob({ - paymentPayload, - paymentRequirements, - usageMetadata, + // x402 clients must receive the concrete settlement result in this HTTP + // response so they can commit the voucher/channel state. Inference usage + // accounting remains asynchronous below; it never blocks the x402 receipt. + const result = await facilitator.settle(paymentPayload, paymentRequirements); + recordPaymentSettlementMetrics(paymentRequirements, result); + + if (result.success && usageMetadata) { + const usageSessionId = usageMetadata.sessionId ?? `payment-${randomUUID()}`; + void recordInferenceUsage( + { ...usageMetadata, sessionId: usageSessionId }, + result.payer, + ).catch(error => { + console.error("[api] Failed to record inference usage", summarizeError(error)); + }); + } + + console.log("[api] /settle request completed", { + ...summarizePaymentRequirements(paymentRequirements), + success: result.success, + transaction: result.transaction, + errorReason: result.success ? undefined : result.errorReason, }); - return res.status(202).json({ paymentJob }); + debugLog("[api][debug] /settle response", result); + return res.json(result); } catch (error) { console.error("[api] /settle request failed", summarizeError(error)); if (isSettlementError(error)) { diff --git a/all_networks_shared.ts b/all_networks_shared.ts index ff3f8ea..ce79ece 100644 --- a/all_networks_shared.ts +++ b/all_networks_shared.ts @@ -4,7 +4,8 @@ import { createErc20ApprovalGasSponsoringExtension, type Erc20ApprovalGasSponsoringSigner, } from "@x402/extensions"; -import { toFacilitatorEvmSigner, type FacilitatorEvmSigner } from "@x402/evm"; +import { toFacilitatorEvmSigner, type AuthorizerSigner, type FacilitatorEvmSigner } from "@x402/evm"; +import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/facilitator"; import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; import { UptoEvmScheme } from "@x402/evm/upto/facilitator"; import { StandardMerkleTree } from "@openzeppelin/merkle-tree"; @@ -1277,6 +1278,21 @@ export async function createFacilitator(): Promise { const evmAccount = privateKeyToAccount(evmPrivateKey); console.info(`EVM Facilitator account: ${evmAccount.address}`); + // Batch settlement needs a receiver authorizer to sign claim/refund EIP-712 + // messages. A dedicated key can be configured; using the facilitator key as + // the fallback preserves the existing single-key deployment configuration. + const receiverAuthorizerPrivateKey = (process.env.BATCH_SETTLEMENT_RECEIVER_AUTHORIZER_PRIVATE_KEY || + evmPrivateKey) as `0x${string}`; + const receiverAuthorizerAccount = privateKeyToAccount(receiverAuthorizerPrivateKey); + const receiverAuthorizerSigner: AuthorizerSigner = { + address: receiverAuthorizerAccount.address, + signTypedData: params => + receiverAuthorizerAccount.signTypedData( + params as Parameters[0], + ), + }; + console.info(`Batch receiver authorizer: ${receiverAuthorizerSigner.address}`); + const viemClient = createWalletClient({ account: evmAccount, chain: ogEvm, @@ -1310,11 +1326,12 @@ export async function createFacilitator(): Promise { message: Record; signature: `0x${string}`; }) => viemClient.verifyTypedData(args as never), - writeContract: (args: { + writeContract: ({ dataSuffix: _dataSuffix, ...args }: { address: `0x${string}`; abi: readonly unknown[]; functionName: string; args: readonly unknown[]; + dataSuffix?: `0x${string}`; }) => viemClient.writeContract({ ...args, @@ -1353,11 +1370,12 @@ export async function createFacilitator(): Promise { message: Record; signature: `0x${string}`; }) => baseViemClient.verifyTypedData(args as never), - writeContract: (args: { + writeContract: ({ dataSuffix: _dataSuffix, ...args }: { address: `0x${string}`; abi: readonly unknown[]; functionName: string; args: readonly unknown[]; + dataSuffix?: `0x${string}`; }) => baseViemClient.writeContract({ ...args, @@ -1403,6 +1421,10 @@ export async function createFacilitator(): Promise { new ExactEvmScheme(baseEvmSigner, { deployERC4337WithEIP6492: true }), ); facilitator.register(BASE_MAINNET_NETWORK, new UptoEvmScheme(baseEvmSigner)); + facilitator.register( + BASE_MAINNET_NETWORK, + new BatchSettlementEvmScheme(baseEvmSigner, receiverAuthorizerSigner), + ); facilitator .registerExtension(EIP2612_GAS_SPONSORING) diff --git a/bazaar.ts b/bazaar.ts deleted file mode 100644 index 5f7cc7a..0000000 --- a/bazaar.ts +++ /dev/null @@ -1,310 +0,0 @@ -/** - * Facilitator with Discovery Extension Example - * - * Demonstrates how to create a facilitator with bazaar discovery extension that - * catalogs discovered x402 resources. - */ - -import { x402Facilitator } from "@x402/core/facilitator"; -import { - PaymentPayload, - PaymentRequirements, - SettleResponse, - VerifyResponse, -} from "@x402/core/types"; -import { toFacilitatorEvmSigner } from "@x402/evm"; -import { ExactEvmScheme } from "@x402/evm/exact/facilitator"; -import { extractDiscoveryInfo, DiscoveryInfo } from "@x402/extensions/bazaar"; -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 -const evmPrivateKey = process.env.EVM_PRIVATE_KEY as `0x${string}` | undefined; - -if (!evmPrivateKey) { - console.error("❌ EVM_PRIVATE_KEY is required"); - process.exit(1); -} - -// Network configuration -const EVM_NETWORK = "eip155:84532"; // Base Sepolia - -// DiscoveredResource represents a discovered x402 resource for the bazaar catalog -interface DiscoveredResource { - resource: string; - description?: string; - mimeType?: string; - type: string; - x402Version: number; - accepts: PaymentRequirements[]; - discoveryInfo?: DiscoveryInfo; - lastUpdated: string; -} - -// BazaarCatalog stores discovered resources -class BazaarCatalog { - private resources: Map = new Map(); - - add(res: DiscoveredResource): void { - this.resources.set(res.resource, res); - } - - getAll(): DiscoveredResource[] { - return Array.from(this.resources.values()); - } -} - -const bazaarCatalog = new BazaarCatalog(); - -// Initialize the x402 Facilitator with discovery hooks -const facilitator = new x402Facilitator() - .onBeforeVerify(async context => { - console.log("Before verify", context); - }) - .onAfterVerify(async context => { - console.log("✅ Payment verified"); - - // Extract discovered resource from payment for bazaar catalog - try { - const discovered = extractDiscoveryInfo( - context.paymentPayload, - context.requirements, - true, // validate - ); - - if (discovered) { - console.log(` 📝 Discovered resource: ${discovered.resourceUrl}`); - console.log(` 📝 Description: ${discovered.description}`); - console.log(` 📝 MimeType: ${discovered.mimeType}`); - console.log(` 📝 Method: ${discovered.method}`); - console.log(` 📝 X402Version: ${discovered.x402Version}`); - - bazaarCatalog.add({ - resource: discovered.resourceUrl, - description: discovered.description, - mimeType: discovered.mimeType, - type: "http", - x402Version: discovered.x402Version, - accepts: [context.requirements], - discoveryInfo: discovered.discoveryInfo, - lastUpdated: new Date().toISOString(), - }); - console.log(" ✅ Added to bazaar catalog"); - } - } catch (err) { - console.log(` ⚠️ Failed to extract discovery info: ${err}`); - } - }) - .onVerifyFailure(async context => { - console.log("Verify failure", context); - }) - .onBeforeSettle(async context => { - console.log("Before settle", context); - }) - .onAfterSettle(async context => { - console.log(`🎉 Payment settled: ${context.result.transaction}`); - }) - .onSettleFailure(async context => { - console.log("Settle failure", context); - }); - -// 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, { deployERC4337WithEIP6492: true }), - ); -} - -// Initialize Express app -const app = express(); -app.use(express.json()); - -/** - * POST /verify - * Verify a payment against requirements - * - * Note: Payment tracking and bazaar discovery are handled by lifecycle hooks - */ -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", - }); - } - - // Hooks will automatically: - // - Track verified payment (onAfterVerify) - // - Extract and catalog discovery info (onAfterVerify) - 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 /discovery/resources - * List all discovered resources from bazaar - */ -app.get("/discovery/resources", async (req, res) => { - try { - const resources = bazaarCatalog.getAll(); - res.json({ - x402Version: 2, - items: resources, - pagination: { - limit: 100, - offset: 0, - total: resources.length, - }, - }); - } catch (error) { - console.error("Discovery 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 -app.listen(parseInt(PORT), () => { - console.log(`🚀 Discovery Facilitator listening on http://localhost:${PORT}`); - console.log(` Supported networks: ${facilitator.getSupported().kinds.map(k => k.network).join(", ")}`); - console.log(` Discovery endpoint: GET /discovery/resources`); - console.log(); -}); diff --git a/package.json b/package.json index daf211a..ea91a2f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ }, "dependencies": { "@openzeppelin/merkle-tree": "^1.0.8", - "@mysten/sui": "^1.38.0", + "@mysten/sui": "1.37.6", "@mysten/walrus": "^0.6.7", "@mysten/walrus-wasm": "^0.1.1", "@x402/core": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 453f922..e3077f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@mysten/sui': - specifier: ^1.38.0 - version: 1.45.2(typescript@5.9.3) + specifier: 1.37.6 + version: 1.37.6(typescript@5.9.3) '@mysten/walrus': specifier: ^0.6.7 version: 0.6.7(typescript@5.9.3) @@ -1529,23 +1529,13 @@ packages: '@mysten/bcs@1.7.0': resolution: {integrity: sha512-8zE2Jzj2ai55RlVXx2pEMbbq+X3vB+uPGBvZr0F79IdTwuwcu4QdFG3PT/zHsytsvATkn+z0f2YDWhM5916u2A==} - '@mysten/bcs@1.9.2': - resolution: {integrity: sha512-kBk5xrxV9OWR7i+JhL/plQrgQ2/KJhB2pB5gj+w6GXhbMQwS3DPpOvi/zN0Tj84jwPvHMllpEl0QHj6ywN7/eQ==} - '@mysten/sui@1.37.6': resolution: {integrity: sha512-7ut5tWuXx9OIE+DQ2D5hd22UB8j8K4/F2Lx3R8Ej/JMwkm6cpgbF5FKNu0vYqehxiFEktMnq2YzzajxmlFHNPw==} engines: {node: '>=18'} - '@mysten/sui@1.45.2': - resolution: {integrity: sha512-gftf7fNpFSiXyfXpbtP2afVEnhc7p2m/MEYc/SO5pov92dacGKOpQIF7etZsGDI1Wvhv+dpph+ulRNpnYSs7Bg==} - engines: {node: '>=18'} - '@mysten/utils@0.1.1': resolution: {integrity: sha512-jvhJC6/2la1QHltukQXzfyTZ+VVHxe187JjPx+mEXRUWyAo6jCSdioOQJIfaGu4K4i+37KeiydXRwV/bq/7UJQ==} - '@mysten/utils@0.2.0': - resolution: {integrity: sha512-CM6kJcJHX365cK6aXfFRLBiuyXc5WSBHQ43t94jqlCAIRw8umgNcTb5EnEA9n31wPAQgLDGgbG/rCUISCTJ66w==} - '@mysten/walrus-wasm@0.1.1': resolution: {integrity: sha512-j+12gNbPAIkgY7g1d4aCF2rXDpgTLQsyjEdu0s7A8xlu/95u5quP7Tc7Jz7VLFfbtkJigBpGyPZ/8J2tZv4cJA==} @@ -1633,10 +1623,6 @@ packages: resolution: {integrity: sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==} engines: {node: ^14.21.3 || >=16} - '@noble/curves@1.9.4': - resolution: {integrity: sha512-2bKONnuM53lINoDrSmK8qP8W271ms7pygDhZt4SiLOoLwBtoHqeCFi6RG42V8zd3mLHuJFhU/Bmaqo4nX0/kBw==} - engines: {node: ^14.21.3 || >=16} - '@noble/curves@1.9.7': resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} engines: {node: ^14.21.3 || >=16} @@ -1691,15 +1677,6 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@protobuf-ts/grpcweb-transport@2.11.1': - resolution: {integrity: sha512-1W4utDdvOB+RHMFQ0soL4JdnxjXV+ddeGIUg08DvZrA8Ms6k5NN6GBFU2oHZdTOcJVpPrDJ02RJlqtaoCMNBtw==} - - '@protobuf-ts/runtime-rpc@2.11.1': - resolution: {integrity: sha512-4CqqUmNA+/uMz00+d3CYKgElXO9VrEbucjnBFEjqI4GuDrEQ32MaI3q+9qPBvIGOlL4PmHXrzM32vBPWRhQKWQ==} - - '@protobuf-ts/runtime@2.11.1': - resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} - '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -5295,14 +5272,6 @@ packages: valibot@0.36.0: resolution: {integrity: sha512-CjF1XN4sUce8sBK9TixrDqFM7RwNkuXdJu174/AwmQUB62QbCQADg5lLe8ldBalFgtj1uKj+pKwDJiNo4Mn+eQ==} - valibot@1.3.1: - resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - valid-url@1.0.9: resolution: {integrity: sha512-QQDsV8OnSf5Uc30CKSwG9lnhMPe6exHtTXLRYX8uMwKENy640pU+2BgBL0LRbDh/eYRahNCS7aewCx0wf3NYVA==} @@ -6413,11 +6382,6 @@ snapshots: '@mysten/utils': 0.1.1 '@scure/base': 1.2.6 - '@mysten/bcs@1.9.2': - dependencies: - '@mysten/utils': 0.2.0 - '@scure/base': 1.2.6 - '@mysten/sui@1.37.6(typescript@5.9.3)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) @@ -6437,36 +6401,10 @@ snapshots: - '@gql.tada/vue-support' - typescript - '@mysten/sui@1.45.2(typescript@5.9.3)': - dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.13.2) - '@mysten/bcs': 1.9.2 - '@mysten/utils': 0.2.0 - '@noble/curves': 1.9.4 - '@noble/hashes': 1.8.0 - '@protobuf-ts/grpcweb-transport': 2.11.1 - '@protobuf-ts/runtime': 2.11.1 - '@protobuf-ts/runtime-rpc': 2.11.1 - '@scure/base': 1.2.6 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - gql.tada: 1.9.2(graphql@16.13.2)(typescript@5.9.3) - graphql: 16.13.2 - poseidon-lite: 0.2.1 - valibot: 1.3.1(typescript@5.9.3) - transitivePeerDependencies: - - '@gql.tada/svelte-support' - - '@gql.tada/vue-support' - - typescript - '@mysten/utils@0.1.1': dependencies: '@scure/base': 1.2.6 - '@mysten/utils@0.2.0': - dependencies: - '@scure/base': 1.2.6 - '@mysten/walrus-wasm@0.1.1': {} '@mysten/walrus@0.6.7(typescript@5.9.3)': @@ -6535,10 +6473,6 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 - '@noble/curves@1.9.4': - dependencies: - '@noble/hashes': 1.8.0 - '@noble/curves@1.9.7': dependencies: '@noble/hashes': 1.8.0 @@ -6581,17 +6515,6 @@ snapshots: '@pkgr/core@0.2.9': {} - '@protobuf-ts/grpcweb-transport@2.11.1': - dependencies: - '@protobuf-ts/runtime': 2.11.1 - '@protobuf-ts/runtime-rpc': 2.11.1 - - '@protobuf-ts/runtime-rpc@2.11.1': - dependencies: - '@protobuf-ts/runtime': 2.11.1 - - '@protobuf-ts/runtime@2.11.1': {} - '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -11434,10 +11357,6 @@ snapshots: valibot@0.36.0: {} - valibot@1.3.1(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - valid-url@1.0.9: {} valtio@1.13.2(@types/react@19.2.2)(react@19.2.0): diff --git a/stuff.sol b/stuff.sol new file mode 100644 index 0000000..aa37dc0 --- /dev/null +++ b/stuff.sol @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.4.16 >=0.8.4 ^0.8.20; + +// lib/openzeppelin-contracts/contracts/utils/Context.sol + +// OpenZeppelin Contracts (last updated v5.0.1) (utils/Context.sol) + +/** + * @dev Provides information about the current execution context, including the + * sender of the transaction and its data. While these are generally available + * via msg.sender and msg.data, they should not be accessed in such a direct + * manner, since when dealing with meta-transactions the account sending and + * paying for execution may not be the actual sender (as far as an application + * is concerned). + * + * This contract is only required for intermediate, library-like contracts. + */ +abstract contract Context { + function _msgSender() internal view virtual returns (address) { + return msg.sender; + } + + function _msgData() internal view virtual returns (bytes calldata) { + return msg.data; + } + + function _contextSuffixLength() internal view virtual returns (uint256) { + return 0; + } +} + +// lib/openzeppelin-contracts/contracts/access/IAccessControl.sol + +// OpenZeppelin Contracts (last updated v5.4.0) (access/IAccessControl.sol) + +/** + * @dev External interface of AccessControl declared to support ERC-165 detection. + */ +interface IAccessControl { + /** + * @dev The `account` is missing a role. + */ + error AccessControlUnauthorizedAccount(address account, bytes32 neededRole); + + /** + * @dev The caller of a function is not the expected one. + * + * NOTE: Don't confuse with {AccessControlUnauthorizedAccount}. + */ + error AccessControlBadConfirmation(); + + /** + * @dev Emitted when `newAdminRole` is set as ``role``'s admin role, replacing `previousAdminRole` + * + * `DEFAULT_ADMIN_ROLE` is the starting admin for all roles, despite + * {RoleAdminChanged} not being emitted to signal this. + */ + event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole); + + /** + * @dev Emitted when `account` is granted `role`. + * + * `sender` is the account that originated the contract call. This account bears the admin role (for the granted role). + * Expected in cases where the role was granted using the internal {AccessControl-_grantRole}. + */ + event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender); + + /** + * @dev Emitted when `account` is revoked `role`. + * + * `sender` is the account that originated the contract call: + * - if using `revokeRole`, it is the admin role bearer + * - if using `renounceRole`, it is the role bearer (i.e. `account`) + */ + event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender); + + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole(bytes32 role, address account) external view returns (bool); + + /** + * @dev Returns the admin role that controls `role`. See {grantRole} and + * {revokeRole}. + * + * To change a role's admin, use {AccessControl-_setRoleAdmin}. + */ + function getRoleAdmin(bytes32 role) external view returns (bytes32); + + /** + * @dev Grants `role` to `account`. + * + * If `account` had not been already granted `role`, emits a {RoleGranted} + * event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function grantRole(bytes32 role, address account) external; + + /** + * @dev Revokes `role` from `account`. + * + * If `account` had been granted `role`, emits a {RoleRevoked} event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + */ + function revokeRole(bytes32 role, address account) external; + + /** + * @dev Revokes `role` from the calling account. + * + * Roles are often managed via {grantRole} and {revokeRole}: this function's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * If the calling account had been granted `role`, emits a {RoleRevoked} + * event. + * + * Requirements: + * + * - the caller must be `callerConfirmation`. + */ + function renounceRole(bytes32 role, address callerConfirmation) external; +} + +// lib/openzeppelin-contracts/contracts/utils/introspection/IERC165.sol + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface(bytes4 interfaceId) external view returns (bool); +} + +// lib/openzeppelin-contracts/contracts/utils/introspection/ERC165.sol + +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/ERC165.sol) + +/** + * @dev Implementation of the {IERC165} interface. + * + * Contracts that want to implement ERC-165 should inherit from this contract and override {supportsInterface} to check + * for the additional interface id that will be supported. For example: + * + * ```solidity + * function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + * return interfaceId == type(MyInterface).interfaceId || super.supportsInterface(interfaceId); + * } + * ``` + */ +abstract contract ERC165 is IERC165 { + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual returns (bool) { + return interfaceId == type(IERC165).interfaceId; + } +} + +// lib/openzeppelin-contracts/contracts/access/Ownable.sol + +// OpenZeppelin Contracts (last updated v5.0.0) (access/Ownable.sol) + +/** + * @dev Contract module which provides a basic access control mechanism, where + * there is an account (an owner) that can be granted exclusive access to + * specific functions. + * + * The initial owner is set to the address provided by the deployer. This can + * later be changed with {transferOwnership}. + * + * This module is used through inheritance. It will make available the modifier + * `onlyOwner`, which can be applied to your functions to restrict their use to + * the owner. + */ +abstract contract Ownable is Context { + address private _owner; + + /** + * @dev The caller account is not authorized to perform an operation. + */ + error OwnableUnauthorizedAccount(address account); + + /** + * @dev The owner is not a valid owner account. (eg. `address(0)`) + */ + error OwnableInvalidOwner(address owner); + + event OwnershipTransferred(address indexed previousOwner, address indexed newOwner); + + /** + * @dev Initializes the contract setting the address provided by the deployer as the initial owner. + */ + constructor(address initialOwner) { + if (initialOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(initialOwner); + } + + /** + * @dev Throws if called by any account other than the owner. + */ + modifier onlyOwner() { + _checkOwner(); + _; + } + + /** + * @dev Returns the address of the current owner. + */ + function owner() public view virtual returns (address) { + return _owner; + } + + /** + * @dev Throws if the sender is not the owner. + */ + function _checkOwner() internal view virtual { + if (owner() != _msgSender()) { + revert OwnableUnauthorizedAccount(_msgSender()); + } + } + + /** + * @dev Leaves the contract without owner. It will not be possible to call + * `onlyOwner` functions. Can only be called by the current owner. + * + * NOTE: Renouncing ownership will leave the contract without an owner, + * thereby disabling any functionality that is only available to the owner. + */ + function renounceOwnership() public virtual onlyOwner { + _transferOwnership(address(0)); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Can only be called by the current owner. + */ + function transferOwnership(address newOwner) public virtual onlyOwner { + if (newOwner == address(0)) { + revert OwnableInvalidOwner(address(0)); + } + _transferOwnership(newOwner); + } + + /** + * @dev Transfers ownership of the contract to a new account (`newOwner`). + * Internal function without access restriction. + */ + function _transferOwnership(address newOwner) internal virtual { + address oldOwner = _owner; + _owner = newOwner; + emit OwnershipTransferred(oldOwner, newOwner); + } +} + +// lib/openzeppelin-contracts/contracts/access/AccessControl.sol + +// OpenZeppelin Contracts (last updated v5.4.0) (access/AccessControl.sol) + +/** + * @dev Contract module that allows children to implement role-based access + * control mechanisms. This is a lightweight version that doesn't allow enumerating role + * members except through off-chain means by accessing the contract event logs. Some + * applications may benefit from on-chain enumerability, for those cases see + * {AccessControlEnumerable}. + * + * Roles are referred to by their `bytes32` identifier. These should be exposed + * in the external API and be unique. The best way to achieve this is by + * using `public constant` hash digests: + * + * ```solidity + * bytes32 public constant MY_ROLE = keccak256("MY_ROLE"); + * ``` + * + * Roles can be used to represent a set of permissions. To restrict access to a + * function call, use {hasRole}: + * + * ```solidity + * function foo() public { + * require(hasRole(MY_ROLE, msg.sender)); + * ... + * } + * ``` + * + * Roles can be granted and revoked dynamically via the {grantRole} and + * {revokeRole} functions. Each role has an associated admin role, and only + * accounts that have a role's admin role can call {grantRole} and {revokeRole}. + * + * By default, the admin role for all roles is `DEFAULT_ADMIN_ROLE`, which means + * that only accounts with this role will be able to grant or revoke other + * roles. More complex role relationships can be created by using + * {_setRoleAdmin}. + * + * WARNING: The `DEFAULT_ADMIN_ROLE` is also its own admin: it has permission to + * grant and revoke this role. Extra precautions should be taken to secure + * accounts that have been granted it. We recommend using {AccessControlDefaultAdminRules} + * to enforce additional security measures for this role. + */ +abstract contract AccessControl is Context, IAccessControl, ERC165 { + struct RoleData { + mapping(address account => bool) hasRole; + bytes32 adminRole; + } + + mapping(bytes32 role => RoleData) private _roles; + + bytes32 public constant DEFAULT_ADMIN_ROLE = 0x00; + + /** + * @dev Modifier that checks that an account has a specific role. Reverts + * with an {AccessControlUnauthorizedAccount} error including the required role. + */ + modifier onlyRole(bytes32 role) { + _checkRole(role); + _; + } + + /// @inheritdoc IERC165 + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return interfaceId == type(IAccessControl).interfaceId || super.supportsInterface(interfaceId); + } + + /** + * @dev Returns `true` if `account` has been granted `role`. + */ + function hasRole(bytes32 role, address account) public view virtual returns (bool) { + return _roles[role].hasRole[account]; + } + + /** + * @dev Reverts with an {AccessControlUnauthorizedAccount} error if `_msgSender()` + * is missing `role`. Overriding this function changes the behavior of the {onlyRole} modifier. + */ + function _checkRole(bytes32 role) internal view virtual { + _checkRole(role, _msgSender()); + } + + /** + * @dev Reverts with an {AccessControlUnauthorizedAccount} error if `account` + * is missing `role`. + */ + function _checkRole(bytes32 role, address account) internal view virtual { + if (!hasRole(role, account)) { + revert AccessControlUnauthorizedAccount(account, role); + } + } + + /** + * @dev Returns the admin role that controls `role`. See {grantRole} and + * {revokeRole}. + * + * To change a role's admin, use {_setRoleAdmin}. + */ + function getRoleAdmin(bytes32 role) public view virtual returns (bytes32) { + return _roles[role].adminRole; + } + + /** + * @dev Grants `role` to `account`. + * + * If `account` had not been already granted `role`, emits a {RoleGranted} + * event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + * + * May emit a {RoleGranted} event. + */ + function grantRole(bytes32 role, address account) public virtual onlyRole(getRoleAdmin(role)) { + _grantRole(role, account); + } + + /** + * @dev Revokes `role` from `account`. + * + * If `account` had been granted `role`, emits a {RoleRevoked} event. + * + * Requirements: + * + * - the caller must have ``role``'s admin role. + * + * May emit a {RoleRevoked} event. + */ + function revokeRole(bytes32 role, address account) public virtual onlyRole(getRoleAdmin(role)) { + _revokeRole(role, account); + } + + /** + * @dev Revokes `role` from the calling account. + * + * Roles are often managed via {grantRole} and {revokeRole}: this function's + * purpose is to provide a mechanism for accounts to lose their privileges + * if they are compromised (such as when a trusted device is misplaced). + * + * If the calling account had been revoked `role`, emits a {RoleRevoked} + * event. + * + * Requirements: + * + * - the caller must be `callerConfirmation`. + * + * May emit a {RoleRevoked} event. + */ + function renounceRole(bytes32 role, address callerConfirmation) public virtual { + if (callerConfirmation != _msgSender()) { + revert AccessControlBadConfirmation(); + } + + _revokeRole(role, callerConfirmation); + } + + /** + * @dev Sets `adminRole` as ``role``'s admin role. + * + * Emits a {RoleAdminChanged} event. + */ + function _setRoleAdmin(bytes32 role, bytes32 adminRole) internal virtual { + bytes32 previousAdminRole = getRoleAdmin(role); + _roles[role].adminRole = adminRole; + emit RoleAdminChanged(role, previousAdminRole, adminRole); + } + + /** + * @dev Attempts to grant `role` to `account` and returns a boolean indicating if `role` was granted. + * + * Internal function without access restriction. + * + * May emit a {RoleGranted} event. + */ + function _grantRole(bytes32 role, address account) internal virtual returns (bool) { + if (!hasRole(role, account)) { + _roles[role].hasRole[account] = true; + emit RoleGranted(role, account, _msgSender()); + return true; + } else { + return false; + } + } + + /** + * @dev Attempts to revoke `role` from `account` and returns a boolean indicating if `role` was revoked. + * + * Internal function without access restriction. + * + * May emit a {RoleRevoked} event. + */ + function _revokeRole(bytes32 role, address account) internal virtual returns (bool) { + if (hasRole(role, account)) { + _roles[role].hasRole[account] = false; + emit RoleRevoked(role, account, _msgSender()); + return true; + } else { + return false; + } + } +} + +pragma solidity ^0.8.20; + +import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol"; + +contract SettlementRelay is Ownable, AccessControl { + bytes32 public constant SETTLEMENT_RELAY_ROLE = keccak256("SETTLEMENT_RELAY_ROLE"); + + event BatchSettlement( + bytes32 indexed merkleRoot, + uint256 batchSize, + bytes walrusBlobId + ); + + event IndividualSettlement( + bytes32 indexed teeId, + address indexed ethAddress, + bytes32 inputHash, + bytes32 outputHash, + uint256 timestamp, + bytes walrusBlobId, + bytes signature + ); + + constructor() Ownable(msg.sender) { + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(SETTLEMENT_RELAY_ROLE, msg.sender); + } + + // --- WRITE FUNCTIONS (Only Relay) --- + + function batchSettle(bytes32 _merkleRoot, uint256 _batchSize, bytes calldata _walrusBlobId) external onlyRole(SETTLEMENT_RELAY_ROLE) { + emit BatchSettlement(_merkleRoot, _batchSize, _walrusBlobId); + } + + function settleIndividual( + bytes32 _teeId, + bytes32 _inputHash, + bytes32 _outputHash, + uint256 _timestamp, + address _ethAddress, + bytes calldata _walrusBlobId, + bytes calldata _signature + ) external onlyRole(SETTLEMENT_RELAY_ROLE) { + emit IndividualSettlement( + _teeId, + _ethAddress, + _inputHash, + _outputHash, + _timestamp, + _walrusBlobId, + _signature + ); + } + + // --- READ / VERIFY FUNCTIONS (Public) --- + + + function verifyProof( + bytes32[] calldata _proof, + bytes32 _root, + bytes32 _leaf + ) external pure returns (bool) { + return MerkleProof.verify(_proof, _root, _leaf); + } +} diff --git a/testing.py b/testing.py new file mode 100644 index 0000000..f8ea64f --- /dev/null +++ b/testing.py @@ -0,0 +1,5 @@ +import base64 +sig_b64 = 'JQ3O2FZ4wLJ14MmfwTmna/gohfqo3lbRSe6d68Cx4hNPuWoVJXXamwNZGgxNCGybEkeZGeZprL5Z1iK7ymtahpH0BSTPV1PQNMLTHaDj/0NT8kdnwSMc5ai7gBFOKliGskuwJ1dsfDYSveMfgF+pJMVv/spX/wuct7QXRDMyDVaWS65awkBfxmJRX9n/1f/fpSFoxy7cTHkUEz1XIGqZ3/8iDWClP+YKLKaO4mYTkS8vcIBg3fwi2YDOMHQ0uoRTXPetXrPrtPlD2Asi5faRK0FkRdae1Zq1DAFxzIxuPL2msUJfSu/UMV+6Yysg5VeypuDDR4OouWC+1WeBFkMn0g==' +sig_bytes = base64.b64decode(sig_b64) +print('hex:', sig_bytes.hex()) +print('length:', len(sig_bytes), 'bytes') diff --git a/typescript/packages/mechanisms/evm/package.json b/typescript/packages/mechanisms/evm/package.json index 9f60222..7390825 100644 --- a/typescript/packages/mechanisms/evm/package.json +++ b/typescript/packages/mechanisms/evm/package.json @@ -149,6 +149,16 @@ "types": "./dist/cjs/upto/facilitator/index.d.ts", "default": "./dist/cjs/upto/facilitator/index.js" } + }, + "./batch-settlement/facilitator": { + "import": { + "types": "./dist/esm/batch-settlement/facilitator/index.d.mts", + "default": "./dist/esm/batch-settlement/facilitator/index.mjs" + }, + "require": { + "types": "./dist/cjs/batch-settlement/facilitator/index.d.ts", + "default": "./dist/cjs/batch-settlement/facilitator/index.js" + } } }, "files": [ diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts new file mode 100644 index 0000000..ee939c5 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/abi.ts @@ -0,0 +1,217 @@ +export const channelConfigComponents = [ + { name: "payer", type: "address" }, + { name: "payerAuthorizer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "receiverAuthorizer", type: "address" }, + { name: "token", type: "address" }, + { name: "withdrawDelay", type: "uint40" }, + { name: "salt", type: "bytes32" }, +] as const; + +const voucherClaimComponents = [ + { + name: "voucher", + type: "tuple", + components: [ + { + name: "channel", + type: "tuple", + components: channelConfigComponents, + }, + { name: "maxClaimableAmount", type: "uint128" }, + ], + }, + { name: "signature", type: "bytes" }, + { name: "totalClaimed", type: "uint128" }, +] as const; + +export const batchSettlementABI = [ + { + type: "function", + name: "multicall", + inputs: [{ name: "data", type: "bytes[]" }], + outputs: [{ name: "results", type: "bytes[]" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "deposit", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + { name: "collector", type: "address" }, + { name: "collectorData", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "claim", + inputs: [{ name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "claimWithSignature", + inputs: [ + { name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }, + { name: "authorizerSignature", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "settle", + inputs: [ + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "initiateWithdraw", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "finalizeWithdraw", + inputs: [{ name: "config", type: "tuple", components: channelConfigComponents }], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "refund", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "refundWithSignature", + inputs: [ + { name: "config", type: "tuple", components: channelConfigComponents }, + { name: "amount", type: "uint128" }, + { name: "nonce", type: "uint256" }, + { name: "receiverAuthorizerSignature", type: "bytes" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "getChannelId", + inputs: [{ name: "config", type: "tuple", components: channelConfigComponents }], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "CHANNEL_CONFIG_TYPEHASH", + inputs: [], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "channels", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "balance", type: "uint128" }, + { name: "totalClaimed", type: "uint128" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "pendingWithdrawals", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [ + { name: "amount", type: "uint128" }, + { name: "initiatedAt", type: "uint40" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "receivers", + inputs: [ + { name: "receiver", type: "address" }, + { name: "token", type: "address" }, + ], + outputs: [ + { name: "totalClaimed", type: "uint128" }, + { name: "totalSettled", type: "uint128" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "getVoucherDigest", + inputs: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + ], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "getRefundDigest", + inputs: [ + { name: "channelId", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "amount", type: "uint128" }, + ], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "function", + name: "refundNonce", + inputs: [{ name: "channelId", type: "bytes32" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "getClaimBatchDigest", + inputs: [{ name: "voucherClaims", type: "tuple[]", components: voucherClaimComponents }], + outputs: [{ name: "", type: "bytes32" }], + stateMutability: "view", + }, + { + type: "event", + name: "Settled", + inputs: [ + { name: "receiver", type: "address", indexed: true }, + { name: "token", type: "address", indexed: true }, + { name: "sender", type: "address", indexed: true }, + { name: "amount", type: "uint128", indexed: false }, + ], + anonymous: false, + }, +] as const; + +export const erc20BalanceOfABI = [ + { + type: "function", + name: "balanceOf", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts new file mode 100644 index 0000000..9c65ea8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/authorizerSigner.ts @@ -0,0 +1,66 @@ +import type { AuthorizerSigner, BatchSettlementVoucherClaim } from "./types"; +import { claimBatchTypes, refundTypes } from "./constants"; +import { computeChannelId, getBatchSettlementEip712Domain } from "./utils"; +import { getEvmChainId } from "../utils"; + +/** + * Signs a `ClaimBatch` EIP-712 digest for `claimWithSignature()`. + * + * @param signer - Authorizer signer holding the `receiverAuthorizer` key. + * @param claims - Voucher claims to include in the batch. + * @param network - CAIP-2 network identifier (e.g. `"eip155:84532"`). + * @returns EIP-712 signature over `ClaimBatch(ClaimEntry[] claims)`. + */ +export async function signClaimBatch( + signer: AuthorizerSigner, + claims: BatchSettlementVoucherClaim[], + network: string, +): Promise<`0x${string}`> { + const chainId = getEvmChainId(network); + + const claimEntries = claims.map(c => ({ + channelId: computeChannelId(c.voucher.channel, chainId), + maxClaimableAmount: BigInt(c.voucher.maxClaimableAmount), + totalClaimed: BigInt(c.totalClaimed), + })); + + return signer.signTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: claimBatchTypes, + primaryType: "ClaimBatch", + message: { + claims: claimEntries, + }, + }); +} + +/** + * Signs a `Refund` EIP-712 digest for `refundWithSignature()`. + * + * @param signer - Authorizer signer holding the `receiverAuthorizer` key. + * @param channelId - Channel to authorize refund for. + * @param amount - Refund amount (capped to unclaimed escrow onchain). + * @param nonce - Must match onchain `refundNonce(channelId)`. + * @param network - CAIP-2 network identifier (e.g. `"eip155:84532"`). + * @returns EIP-712 signature over `Refund(channelId, nonce, amount)`. + */ +export async function signRefund( + signer: AuthorizerSigner, + channelId: `0x${string}`, + amount: string, + nonce: string, + network: string, +): Promise<`0x${string}`> { + const chainId = getEvmChainId(network); + + return signer.signTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: refundTypes, + primaryType: "Refund", + message: { + channelId, + nonce: BigInt(nonce), + amount: BigInt(amount), + }, + }); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts new file mode 100644 index 0000000..7b01805 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/constants.ts @@ -0,0 +1,102 @@ +import { keccak256, toBytes } from "viem"; + +/** Scheme identifier for the batch-settlement payment scheme. */ +export const BATCH_SETTLEMENT_SCHEME = "batch-settlement" as const; + +/** Deployed address of the x402BatchSettlement contract. */ +export const BATCH_SETTLEMENT_ADDRESS = "0x4020074e9dF2ce1deE5A9C1b5c3f541D02a10003" as const; + +/** Deployed address of the ERC3009DepositCollector contract. */ +export const ERC3009_DEPOSIT_COLLECTOR_ADDRESS = + "0x4020806089470a89826cB9fB1f4059150b550004" as const; + +/** Deployed address of the Permit2DepositCollector contract. */ +export const PERMIT2_DEPOSIT_COLLECTOR_ADDRESS = + "0x4020425FAf3B746C082C2f942b4E5159887B0005" as const; + +/** Minimum withdraw delay in seconds (15 minutes), matching the onchain constant. */ +export const MIN_WITHDRAW_DELAY = 900; + +/** Maximum withdraw delay in seconds (30 days), matching the onchain constant. */ +export const MAX_WITHDRAW_DELAY = 2_592_000; + +/** EIP-712 domain fields shared across all batch-settlement typed-data signatures. */ +export const BATCH_SETTLEMENT_DOMAIN = { + name: "x402 Batch Settlement", + version: "1", +} as const; + +/** EIP-712 type hash for channel identity. */ +export const CHANNEL_CONFIG_TYPEHASH = keccak256( + toBytes( + "ChannelConfig(address payer,address payerAuthorizer,address receiver,address receiverAuthorizer,address token,uint40 withdrawDelay,bytes32 salt)", + ), +); + +/** EIP-712 type definition for a channel configuration. */ +export const channelConfigTypes = { + ChannelConfig: [ + { name: "payer", type: "address" }, + { name: "payerAuthorizer", type: "address" }, + { name: "receiver", type: "address" }, + { name: "receiverAuthorizer", type: "address" }, + { name: "token", type: "address" }, + { name: "withdrawDelay", type: "uint40" }, + { name: "salt", type: "bytes32" }, + ], +} as const; + +/** EIP-712 type definition for a cumulative voucher: `Voucher(bytes32 channelId, uint128 maxClaimableAmount)`. */ +export const voucherTypes = { + Voucher: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definition for cooperative refund: `Refund(bytes32 channelId, uint256 nonce, uint128 amount)`. */ +export const refundTypes = { + Refund: [ + { name: "channelId", type: "bytes32" }, + { name: "nonce", type: "uint256" }, + { name: "amount", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definitions for a receiver-authorizer claim batch (nested ClaimEntry). */ +export const claimBatchTypes = { + ClaimBatch: [{ name: "claims", type: "ClaimEntry[]" }], + ClaimEntry: [ + { name: "channelId", type: "bytes32" }, + { name: "maxClaimableAmount", type: "uint128" }, + { name: "totalClaimed", type: "uint128" }, + ], +} as const; + +/** EIP-712 type definition for ERC-3009 `ReceiveWithAuthorization` (used for gasless deposits). */ +export const receiveAuthorizationTypes = { + ReceiveWithAuthorization: [ + { name: "from", type: "address" }, + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + { name: "validAfter", type: "uint256" }, + { name: "validBefore", type: "uint256" }, + { name: "nonce", type: "bytes32" }, + ], +} as const; + +/** Permit2 typed data for channel-bound batch deposits. */ +export const batchPermit2WitnessTypes = { + PermitWitnessTransferFrom: [ + { name: "permitted", type: "TokenPermissions" }, + { name: "spender", type: "address" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "witness", type: "DepositWitness" }, + ], + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + DepositWitness: [{ name: "channelId", type: "bytes32" }], +} as const; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts new file mode 100644 index 0000000..5723d9a --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/encoding.ts @@ -0,0 +1,94 @@ +/** + * @file Encoding helpers for batch-settlement deposit collectors. + */ +import { encodeAbiParameters, keccak256 } from "viem"; + +/** + * Computes the ERC-3009 nonce used by the deposit collector: + * `keccak256(abi.encode(channelId, salt))`. + * + * @param channelId - The `bytes32` channel id binding the authorization to a channel. + * @param salt - Random salt provided by the client to make the nonce unique per deposit. + * @returns The `bytes32` ERC-3009 nonce. + */ +export function buildErc3009DepositNonce( + channelId: `0x${string}`, + salt: `0x${string}`, +): `0x${string}` { + return keccak256( + encodeAbiParameters([{ type: "bytes32" }, { type: "uint256" }], [channelId, BigInt(salt)]), + ); +} + +/** + * Encodes the `collectorData` payload for `ERC3009DepositCollector.collect()`: + * `abi.encode(validAfter, validBefore, salt, signature)`. + * + * @param validAfter - Earliest unix timestamp the authorization is valid (decimal string). + * @param validBefore - Latest unix timestamp the authorization is valid (decimal string). + * @param salt - Random salt provided by the client (hex string). + * @param signature - ERC-3009 `ReceiveWithAuthorization` signature. + * @returns ABI-encoded collector data passed to `deposit(..., collector, collectorData)`. + */ +export function buildErc3009CollectorData( + validAfter: string, + validBefore: string, + salt: `0x${string}`, + signature: `0x${string}`, +): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + [BigInt(validAfter), BigInt(validBefore), BigInt(salt), signature], + ); +} + +/** + * Encodes optional EIP-2612 permit data consumed by `Permit2DepositCollector`. + * + * @param params - Permit amount, deadline, and split signature fields. + * @param params.value - Approved Permit2 allowance value. + * @param params.deadline - EIP-2612 permit deadline. + * @param params.v - Signature recovery id. + * @param params.r - Signature `r` value. + * @param params.s - Signature `s` value. + * @returns ABI-encoded permit segment. + */ +export function buildEip2612PermitData(params: { + value: string; + deadline: string; + v: number; + r: `0x${string}`; + s: `0x${string}`; +}): `0x${string}` { + return encodeAbiParameters( + [ + { type: "uint256" }, + { type: "uint256" }, + { type: "uint8" }, + { type: "bytes32" }, + { type: "bytes32" }, + ], + [BigInt(params.value), BigInt(params.deadline), params.v, params.r, params.s], + ); +} + +/** + * Encodes the `collectorData` payload for `Permit2DepositCollector.collect()`. + * + * @param nonce - Permit2 transfer nonce. + * @param deadline - Permit2 transfer deadline. + * @param permit2Signature - Signature over the channel-bound Permit2 authorization. + * @param eip2612PermitData - Optional encoded EIP-2612 permit segment. + * @returns ABI-encoded collector data passed to `deposit`. + */ +export function buildPermit2CollectorData( + nonce: string, + deadline: string, + permit2Signature: `0x${string}`, + eip2612PermitData: `0x${string}` = "0x", +): `0x${string}` { + return encodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }, { type: "bytes" }], + [BigInt(nonce), BigInt(deadline), permit2Signature, eip2612PermitData], + ); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts new file mode 100644 index 0000000..048192d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/errors.ts @@ -0,0 +1,75 @@ +/** Error codes for the batch-settlement EVM scheme (see scheme_batch_settlement_evm2.md). */ + +export const ErrChannelNotFound = "invalid_batch_settlement_evm_channel_not_found"; +export const ErrTokenMismatch = "invalid_batch_settlement_evm_token_mismatch"; +export const ErrInvalidVoucherSignature = "invalid_batch_settlement_evm_voucher_signature"; +export const ErrCumulativeExceedsBalance = + "invalid_batch_settlement_evm_cumulative_exceeds_balance"; +export const ErrCumulativeAmountBelowClaimed = + "invalid_batch_settlement_evm_cumulative_below_claimed"; +export const ErrInsufficientBalance = "invalid_batch_settlement_evm_insufficient_balance"; +export const ErrDepositTransactionFailed = + "invalid_batch_settlement_evm_deposit_transaction_failed"; +export const ErrClaimTransactionFailed = "invalid_batch_settlement_evm_claim_transaction_failed"; +export const ErrSettleTransactionFailed = "invalid_batch_settlement_evm_settle_transaction_failed"; +export const ErrInvalidScheme = "invalid_batch_settlement_evm_scheme"; +export const ErrNetworkMismatch = "invalid_batch_settlement_evm_network_mismatch"; +export const ErrMissingEip712Domain = "invalid_batch_settlement_evm_missing_eip712_domain"; +export const ErrValidBeforeExpired = + "invalid_batch_settlement_evm_payload_authorization_valid_before"; +export const ErrValidAfterInFuture = + "invalid_batch_settlement_evm_payload_authorization_valid_after"; +export const ErrInvalidReceiveAuthorizationSignature = + "invalid_batch_settlement_evm_receive_authorization_signature"; +export const ErrErc3009AuthorizationRequired = + "invalid_batch_settlement_evm_erc3009_authorization_required"; +export const ErrRefundTransactionFailed = "invalid_batch_settlement_evm_refund_transaction_failed"; +export const ErrInvalidPayloadType = "invalid_batch_settlement_evm_payload_type"; +export const ErrWithdrawDelayOutOfRange = + "invalid_batch_settlement_evm_withdraw_delay_out_of_range"; +export const ErrChannelIdMismatch = "invalid_batch_settlement_evm_channel_id_mismatch"; +export const ErrReceiverMismatch = "invalid_batch_settlement_evm_receiver_mismatch"; +export const ErrReceiverAuthorizerMismatch = + "invalid_batch_settlement_evm_receiver_authorizer_mismatch"; +export const ErrWithdrawDelayMismatch = "invalid_batch_settlement_evm_withdraw_delay_mismatch"; +export const ErrAuthorizerAddressMismatch = + "invalid_batch_settlement_evm_authorizer_address_mismatch"; +export const ErrAuthorizerNotConfigured = "invalid_batch_settlement_evm_authorizer_not_configured"; +export const ErrDepositSimulationFailed = "invalid_batch_settlement_evm_deposit_simulation_failed"; + +// ERC-6492 counterfactual deployment errors (ERC-3009 deposit path). Wire values keep the +// scheme prefix to match the rest of this module's contract. +export const ErrFactoryNotAllowed = "invalid_batch_settlement_evm_eip6492_factory_not_allowed"; +export const ErrSmartWalletDeploymentFailed = + "invalid_batch_settlement_evm_smart_wallet_deployment_failed"; +export const ErrClaimSimulationFailed = "invalid_batch_settlement_evm_claim_simulation_failed"; +export const ErrSettleSimulationFailed = "invalid_batch_settlement_evm_settle_simulation_failed"; +export const ErrNothingToSettle = "invalid_batch_settlement_evm_nothing_to_settle"; +export const ErrRefundPayload = "invalid_batch_settlement_evm_refund_payload"; +export const ErrRefundSimulationFailed = "invalid_batch_settlement_evm_refund_simulation_failed"; +export const ErrRpcReadFailed = "invalid_batch_settlement_evm_rpc_read_failed"; +export const ErrPermit2AuthorizationRequired = + "invalid_batch_settlement_evm_permit2_authorization_required"; +export const ErrPermit2InvalidSpender = "invalid_batch_settlement_evm_permit2_invalid_spender"; +export const ErrPermit2AmountMismatch = "invalid_batch_settlement_evm_permit2_amount_mismatch"; +export const ErrPermit2DeadlineExpired = "invalid_batch_settlement_evm_permit2_deadline_expired"; +export const ErrPermit2InvalidSignature = "invalid_batch_settlement_evm_permit2_invalid_signature"; +export const ErrPermit2AllowanceRequired = + "invalid_batch_settlement_evm_permit2_allowance_required"; +export const ErrEip2612AmountMismatch = "invalid_batch_settlement_evm_eip2612_amount_mismatch"; +export const ErrEip2612OwnerMismatch = "invalid_batch_settlement_evm_eip2612_owner_mismatch"; +export const ErrEip2612AssetMismatch = "invalid_batch_settlement_evm_eip2612_asset_mismatch"; +export const ErrEip2612SpenderMismatch = "invalid_batch_settlement_evm_eip2612_spender_mismatch"; +export const ErrEip2612DeadlineExpired = "invalid_batch_settlement_evm_eip2612_deadline_expired"; +export const ErrErc20ApprovalUnavailable = + "invalid_batch_settlement_evm_erc20_approval_unavailable"; + +/** Resource server: 402 `error` and lifecycle `reason` (same strings as the spec). */ +export const ErrCumulativeAmountMismatch = + "invalid_batch_settlement_evm_cumulative_amount_mismatch"; +export const ErrChannelBusy = "invalid_batch_settlement_evm_channel_busy"; +export const ErrChargeExceedsSignedCumulative = + "invalid_batch_settlement_evm_charge_exceeds_signed_cumulative"; +export const ErrMissingChannel = "invalid_batch_settlement_evm_missing_channel"; +export const ErrRefundNoBalance = "invalid_batch_settlement_evm_refund_no_balance"; +export const ErrRefundAmountInvalid = "invalid_batch_settlement_evm_refund_amount_invalid"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts new file mode 100644 index 0000000..6f3bf59 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/claim.ts @@ -0,0 +1,135 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { AuthorizerSigner, BatchSettlementClaimPayload } from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { signClaimBatch } from "../authorizerSigner"; +import * as Errors from "../errors"; +import { toContractChannelConfig } from "./utils"; + +/** + * Converts an array of {@link BatchSettlementVoucherClaim} into the onchain tuple format + * expected by the contract's `claimWithSignature()` function. + * + * @param claims - Typed voucher claims with channel config, amounts, and signatures. + * @returns Contract-ready VoucherClaim argument array. + */ +export function buildVoucherClaimArgs(claims: BatchSettlementClaimPayload["claims"]) { + return claims.map(c => ({ + voucher: { + channel: toContractChannelConfig(c.voucher.channel), + maxClaimableAmount: BigInt(c.voucher.maxClaimableAmount), + }, + signature: c.signature, + totalClaimed: BigInt(c.totalClaimed), + })); +} + +/** + * Submits a batch claim via `claimWithSignature()`. + * + * When `claimAuthorizerSignature` is present in the payload it is used directly. + * When absent the facilitator signs the `ClaimBatch` EIP-712 digest using + * `authorizerSigner`, after verifying that every claim's `receiverAuthorizer` + * matches `authorizerSigner.address`. + * + * @param signer - Facilitator signer used to submit the claim transaction. + * @param payload - Claim payload containing voucher claims and optional authorizer signature. + * @param requirements - Payment requirements for network identification. + * @param authorizerSigner - Optional dedicated key for producing `ClaimBatch` EIP-712 signatures. + * When omitted, the payload must already carry a `claimAuthorizerSignature`. + * @param dataSuffix - Optional hex suffix appended to the claim transaction. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeClaimWithSignature( + signer: FacilitatorEvmSigner, + payload: BatchSettlementClaimPayload, + requirements: PaymentRequirements, + authorizerSigner: AuthorizerSigner | undefined, + dataSuffix?: `0x${string}`, +): Promise { + const network = requirements.network; + const claimArgs = buildVoucherClaimArgs(payload.claims); + + let sig = payload.claimAuthorizerSignature; + + if (!sig) { + if (!authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } + for (const claim of payload.claims) { + if ( + getAddress(claim.voucher.channel.receiverAuthorizer) !== + getAddress(authorizerSigner.address) + ) { + return { + success: false, + errorReason: Errors.ErrAuthorizerAddressMismatch, + transaction: "", + network, + }; + } + } + sig = await signClaimBatch(authorizerSigner, payload.claims, network); + } + + try { + await signer.readContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [claimArgs, sig], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrClaimSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + try { + const tx = await signer.writeContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [claimArgs, sig], + dataSuffix, + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrClaimTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + return { + success: true, + transaction: tx, + network, + amount: "", + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrClaimTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts new file mode 100644 index 0000000..29bb520 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts @@ -0,0 +1,218 @@ +import { PaymentRequirements, VerifyResponse } from "@x402/core/types"; +import { getAddress, isAddressEqual, parseErc6492Signature } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BatchSettlementDepositPayload } from "../types"; +import { ERC3009_DEPOSIT_COLLECTOR_ADDRESS, receiveAuthorizationTypes } from "../constants"; +import { buildErc3009CollectorData, buildErc3009DepositNonce } from "../encoding"; +import * as Errors from "../errors"; +import { erc3009AuthorizationTimeInvalidReason } from "./utils"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** Factory deployment info for an undeployed (ERC-6492 counterfactual) deposit wallet. */ +export type Erc3009CounterfactualDeployment = { + factory: `0x${string}`; + factoryCalldata: `0x${string}`; +}; + +/** + * Result of ERC-3009 deposit authorization verification. + * + * - `response` non-null: terminal verification outcome (a rejection). + * - `counterfactual` non-null: the deposit is from an undeployed ERC-6492 wallet with an + * allowlisted factory; the caller must validate the inner signature via the deploy+deposit + * simulation rather than a direct (no-code) signature check. + * - both null: a deployed wallet / plain EOA signature is valid. + */ +export type Erc3009DepositVerifyResult = { + response: VerifyResponse | null; + counterfactual: Erc3009CounterfactualDeployment | null; +}; + +/** + * Returns the collector contract used for EIP-3009 deposits. + * + * @returns ERC-3009 deposit collector address. + */ +export function getEip3009DepositCollectorAddress(): `0x${string}` { + return getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS); +} + +/** + * Encodes collector data for an EIP-3009 deposit payload. + * + * @param payload - Deposit payload containing the ERC-3009 authorization. + * @returns ABI-encoded collector data. + */ +export function buildEip3009DepositCollectorData( + payload: BatchSettlementDepositPayload, +): `0x${string}` { + const auth = payload.deposit.authorization.erc3009Authorization; + if (!auth) { + throw new Error(Errors.ErrErc3009AuthorizationRequired); + } + + const { signature } = parseErc6492Signature(auth.signature); + return buildErc3009CollectorData(auth.validAfter, auth.validBefore, auth.salt, signature); +} + +/** + * Verifies the ERC-3009 authorization fields and typed-data signature. + * + * @param signer - Facilitator signer for typed-data verification. + * @param payload - Deposit payload to verify. + * @param requirements - Payment requirements containing token domain metadata. + * @param chainId - EVM chain id. + * @param allowedFactories - Allowlisted ERC-6492 factory addresses for counterfactual deposits. + * @returns The verification result (rejection, valid, or counterfactual-deferred). + */ +export async function verifyEip3009DepositAuthorization( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, + allowedFactories: string[] = [], +): Promise { + const { deposit, voucher } = payload; + const payer = payload.channelConfig.payer; + const auth = deposit.authorization.erc3009Authorization; + + const reject = (invalidReason: string): Erc3009DepositVerifyResult => ({ + response: { isValid: false, invalidReason, payer }, + counterfactual: null, + }); + + if (!auth) { + return reject(Errors.ErrErc3009AuthorizationRequired); + } + + const extra = requirements.extra as { name?: string; version?: string } | undefined; + if (!extra?.name || !extra?.version) { + return reject(Errors.ErrMissingEip712Domain); + } + + const validAfter = BigInt(auth.validAfter); + const validBefore = BigInt(auth.validBefore); + const timeInvalid = erc3009AuthorizationTimeInvalidReason(validAfter, validBefore); + if (timeInvalid) { + return reject(timeInvalid); + } + + // Parse the ERC-6492 wrapper (a no-op for unwrapped signatures, which return the signature + // unchanged as `signature`). + const { + address: factory, + data: factoryCalldata, + signature: innerSig, + } = parseErc6492Signature(auth.signature); + const hasDeploymentInfo = !!( + factory && + factoryCalldata && + !isAddressEqual(factory, ZERO_ADDRESS) + ); + + // Counterfactual detection: only fetch code when there is deployment info so the common + // (already-deployed / plain EOA) path keeps a single RPC round-trip. + if (hasDeploymentInfo) { + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payer }); + } catch { + code = undefined; + } + if (!code || code === "0x") { + const normalizedFactory = factory.toLowerCase(); + const isAllowed = allowedFactories.some(a => a.trim().toLowerCase() === normalizedFactory); + if (!isAllowed) { + return reject(Errors.ErrFactoryNotAllowed); + } + // Counterfactual + allowlisted: defer signature validation to the deploy+deposit + // simulation performed by the caller. + return { + response: null, + counterfactual: { factory, factoryCalldata: factoryCalldata as `0x${string}` }, + }; + } + // Already deployed despite the wrapper — fall through and validate the inner signature. + } + + const erc3009Nonce = buildErc3009DepositNonce(voucher.channelId, auth.salt); + const receiveAuthOk = await verifyReceiveAuth(signer, { + payer, + asset: requirements.asset, + name: extra.name, + version: extra.version, + chainId, + amount: deposit.amount, + validAfter, + validBefore, + nonce: erc3009Nonce, + signature: innerSig, + }); + + if (!receiveAuthOk) { + return reject(Errors.ErrInvalidReceiveAuthorizationSignature); + } + + return { response: null, counterfactual: null }; +} + +/** + * Verifies a `ReceiveWithAuthorization` signature. + * + * @param signer - Facilitator signer used for typed-data verification. + * @param params - Authorization fields and signature. + * @param params.payer - Expected authorization signer. + * @param params.asset - ERC-20 verifying contract. + * @param params.name - ERC-20 EIP-712 domain name. + * @param params.version - ERC-20 EIP-712 domain version. + * @param params.chainId - EVM chain id. + * @param params.amount - Authorized token amount. + * @param params.validAfter - Earliest valid timestamp. + * @param params.validBefore - Expiration timestamp. + * @param params.nonce - ERC-3009 nonce. + * @param params.signature - Receive authorization signature. + * @returns True when the signature matches the expected payer. + */ +async function verifyReceiveAuth( + signer: FacilitatorEvmSigner, + params: { + payer: `0x${string}`; + asset: string; + name: string; + version: string; + chainId: number; + amount: string; + validAfter: bigint; + validBefore: bigint; + nonce: `0x${string}`; + signature: `0x${string}`; + }, +): Promise { + // Mirror the token's on-chain ERC-3009 signature check. Modern tokens (USDC v2.2) + // use a SignatureChecker that routes by code.length: ECDSA for EOAs, strict + // EIP-1271 for any address with code (including 7702-delegated EOAs). No + // ECDSA fallback for code addresses — that fallback would accept sigs the + // token rejects on-chain. + return verifyTypedDataSignature(signer, { + address: getAddress(params.payer), + domain: { + name: params.name, + version: params.version, + chainId: params.chainId, + verifyingContract: getAddress(params.asset), + }, + types: receiveAuthorizationTypes, + primaryType: "ReceiveWithAuthorization", + message: { + from: getAddress(params.payer), + to: getAddress(ERC3009_DEPOSIT_COLLECTOR_ADDRESS), + value: BigInt(params.amount), + validAfter: params.validAfter, + validBefore: params.validBefore, + nonce: params.nonce, + }, + signature: params.signature, + }); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts new file mode 100644 index 0000000..6b7f392 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts @@ -0,0 +1,368 @@ +import { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + VerifyResponse, +} from "@x402/core/types"; +import { encodeFunctionData, getAddress, parseErc6492Signature } from "viem"; +import { + extractEip2612GasSponsoringInfo, + extractErc20ApprovalGasSponsoringInfo, + ERC20_APPROVAL_GAS_SPONSORING_KEY, + resolveErc20ApprovalExtensionSigner, + type Eip2612GasSponsoringInfo, + type Erc20ApprovalGasSponsoringFacilitatorExtension, + type Erc20ApprovalGasSponsoringSigner, +} from "../../exact/extensions"; +import { appendDataSuffix } from "../../shared/extensions"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; +import { validateErc20ApprovalForPayment } from "../../shared/erc20approval"; +import { validateEip2612PermitForPayment, splitEip2612Signature } from "../../shared/permit2"; +import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { FacilitatorEvmSigner } from "../../signer"; +import { batchSettlementABI } from "../abi"; +import { + BATCH_SETTLEMENT_ADDRESS, + PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + batchPermit2WitnessTypes, +} from "../constants"; +import { buildEip2612PermitData, buildPermit2CollectorData } from "../encoding"; +import { BatchSettlementDepositPayload } from "../types"; +import { toContractChannelConfig } from "./utils"; +import * as Errors from "../errors"; + +export type Permit2DepositBranch = + | { + kind: "standard"; + collectorData: `0x${string}`; + } + | { + kind: "eip2612"; + collectorData: `0x${string}`; + } + | { + kind: "erc20Approval"; + collectorData: `0x${string}`; + signedTransaction: `0x${string}`; + extensionSigner: Erc20ApprovalGasSponsoringSigner; + }; + +/** + * Returns the collector contract used for Permit2 deposits. + * + * @returns Permit2 deposit collector address. + */ +export function getPermit2DepositCollectorAddress(): `0x${string}` { + return getAddress(PERMIT2_DEPOSIT_COLLECTOR_ADDRESS); +} + +/** + * Encodes collector data for a Permit2 deposit payload. + * + * @param payload - Deposit payload containing the Permit2 authorization. + * @param eip2612PermitData - Optional encoded EIP-2612 permit segment. + * @returns ABI-encoded collector data. + */ +export function buildPermit2DepositCollectorData( + payload: BatchSettlementDepositPayload, + eip2612PermitData: `0x${string}` = "0x", +): `0x${string}` { + const auth = payload.deposit.authorization.permit2Authorization; + if (!auth) { + throw new Error(Errors.ErrPermit2AuthorizationRequired); + } + + const { signature } = parseErc6492Signature(auth.signature); + return buildPermit2CollectorData(auth.nonce, auth.deadline, signature, eip2612PermitData); +} + +/** + * Verifies Permit2 authorization fields, setup branch, and approval-bundle simulation. + * + * @param signer - Facilitator signer for reads and signature verification. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param chainId - EVM chain id. + * @param context - Optional facilitator extension context. + * @returns A failure response, or `null` when valid. + */ +export async function verifyPermit2DepositAuthorization( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, + context?: FacilitatorContext, +): Promise { + const authResult = await verifyPermit2TypedData(signer, payload, requirements, chainId); + if (authResult) { + return authResult; + } + + const branchResult = await resolvePermit2DepositBranch( + signer, + payment, + payload, + requirements, + context, + ); + if ("isValid" in branchResult) { + return branchResult; + } + + if (branchResult.kind !== "erc20Approval" || !branchResult.extensionSigner.simulateTransactions) { + return null; + } + + const ok = await branchResult.extensionSigner.simulateTransactions([ + branchResult.signedTransaction, + buildDepositTransaction(payload, branchResult.collectorData), + ]); + if (!ok) { + return { + isValid: false, + invalidReason: Errors.ErrDepositSimulationFailed, + payer: payload.channelConfig.payer, + }; + } + + return null; +} + +/** + * Resolves the Permit2 setup branch and collector data for verification or settlement. + * + * @param signer - Facilitator signer for allowance reads. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param context - Optional facilitator extension context. + * @returns Resolved branch, or a verification failure response. + */ +export async function resolvePermit2DepositBranch( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const payer = payload.channelConfig.payer; + const tokenAddress = getAddress(requirements.asset); + const eip2612Info = extractEip2612GasSponsoringInfo(payment); + if (eip2612Info) { + const result = validateBatchEip2612Permit( + eip2612Info, + payer, + tokenAddress, + payload.deposit.amount, + ); + if (!result.isValid) { + return { isValid: false, invalidReason: result.invalidReason, payer }; + } + + const { v, r, s } = splitEip2612Signature(eip2612Info.signature); + return { + kind: "eip2612", + collectorData: buildPermit2DepositCollectorData( + payload, + buildEip2612PermitData({ + value: eip2612Info.amount, + deadline: eip2612Info.deadline, + v, + r, + s, + }), + ), + }; + } + + const erc20Info = extractErc20ApprovalGasSponsoringInfo(payment); + if (erc20Info) { + const extensionSigner = resolveErc20ApprovalExtensionSigner( + context?.getExtension( + ERC20_APPROVAL_GAS_SPONSORING_KEY, + ), + requirements.network, + ); + if (!extensionSigner) { + return { isValid: false, invalidReason: Errors.ErrErc20ApprovalUnavailable, payer }; + } + + const result = await validateErc20ApprovalForPayment(erc20Info, payer, tokenAddress); + if (!result.isValid) { + return { + isValid: false, + invalidReason: result.invalidReason, + invalidMessage: result.invalidMessage, + payer, + }; + } + + return { + kind: "erc20Approval", + collectorData: buildPermit2DepositCollectorData(payload), + signedTransaction: erc20Info.signedTransaction, + extensionSigner, + }; + } + + try { + const allowance = (await signer.readContract({ + address: tokenAddress, + abi: erc20AllowanceAbi, + functionName: "allowance", + args: [payer, PERMIT2_ADDRESS], + })) as bigint; + + if (allowance < BigInt(payload.deposit.amount)) { + return { isValid: false, invalidReason: Errors.ErrPermit2AllowanceRequired, payer }; + } + } catch { + return { isValid: false, invalidReason: Errors.ErrPermit2AllowanceRequired, payer }; + } + + return { + kind: "standard", + collectorData: buildPermit2DepositCollectorData(payload), + }; +} + +/** + * Builds the unsigned batch `deposit` transaction used after a sponsored approval. + * + * @param payload - Batch deposit payload. + * @param collectorData - Encoded Permit2 collector data. + * @param dataSuffix - Optional hex suffix appended to the deposit calldata. + * @returns Transaction request for the extension signer. + */ +export function buildDepositTransaction( + payload: BatchSettlementDepositPayload, + collectorData: `0x${string}`, + dataSuffix?: `0x${string}`, +): { to: `0x${string}`; data: `0x${string}`; gas: bigint } { + const data = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.deposit.amount), + getPermit2DepositCollectorAddress(), + collectorData, + ], + }); + + return { + to: getAddress(BATCH_SETTLEMENT_ADDRESS), + data: appendDataSuffix(data, dataSuffix), + gas: 300_000n, + }; +} + +/** + * Verifies the channel-bound Permit2 typed-data authorization. + * + * @param signer - Facilitator signer for typed-data verification. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for token matching. + * @param chainId - EVM chain id. + * @returns A failure response, or `null` when valid. + */ +async function verifyPermit2TypedData( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + chainId: number, +): Promise { + const auth = payload.deposit.authorization.permit2Authorization; + const payer = payload.channelConfig.payer; + + if (!auth) { + return { isValid: false, invalidReason: Errors.ErrPermit2AuthorizationRequired, payer }; + } + + if (getAddress(auth.from) !== getAddress(payer)) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; + } + + if (getAddress(auth.spender) !== getPermit2DepositCollectorAddress()) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSpender, payer }; + } + + if (getAddress(auth.permitted.token) !== getAddress(requirements.asset)) { + return { isValid: false, invalidReason: Errors.ErrTokenMismatch, payer }; + } + + if (BigInt(auth.permitted.amount) !== BigInt(payload.deposit.amount)) { + return { isValid: false, invalidReason: Errors.ErrPermit2AmountMismatch, payer }; + } + + if (auth.witness.channelId !== payload.voucher.channelId) { + return { isValid: false, invalidReason: Errors.ErrChannelIdMismatch, payer }; + } + + const now = Math.floor(Date.now() / 1000); + if (BigInt(auth.deadline) < BigInt(now + 6)) { + return { isValid: false, invalidReason: Errors.ErrPermit2DeadlineExpired, payer }; + } + + // Mirror Permit2's on-chain SignatureVerification: ecrecover when the address + // has no code, strict EIP-1271 isValidSignature when it does. No ECDSA + // fallback for code addresses — that fallback would accept sigs Permit2 + // rejects on-chain (notably for ERC-7702 EOAs whose delegate doesn't accept + // raw owner ECDSA). + const ok = await verifyTypedDataSignature(signer, { + address: getAddress(auth.from), + domain: { name: "Permit2", chainId, verifyingContract: PERMIT2_ADDRESS }, + types: batchPermit2WitnessTypes, + primaryType: "PermitWitnessTransferFrom", + message: { + permitted: { + token: getAddress(auth.permitted.token), + amount: BigInt(auth.permitted.amount), + }, + spender: getAddress(auth.spender), + nonce: BigInt(auth.nonce), + deadline: BigInt(auth.deadline), + witness: { + channelId: auth.witness.channelId, + }, + }, + signature: auth.signature, + }); + if (!ok) { + return { isValid: false, invalidReason: Errors.ErrPermit2InvalidSignature, payer }; + } + + return null; +} + +/** + * Applies batch-specific EIP-2612 validation on top of the shared Permit2 checks. + * + * @param info - EIP-2612 sponsoring info from the payment envelope. + * @param payer - Expected token owner. + * @param tokenAddress - Expected token contract. + * @param depositAmount - Required approval amount. + * @returns Validation result. + */ +function validateBatchEip2612Permit( + info: Eip2612GasSponsoringInfo, + payer: `0x${string}`, + tokenAddress: `0x${string}`, + depositAmount: string, +): { isValid: true } | { isValid: false; invalidReason: string } { + const baseline = validateEip2612PermitForPayment(info, payer, tokenAddress); + if (!baseline.isValid) { + return { + isValid: false, + invalidReason: baseline.invalidReason ?? Errors.ErrInvalidPayloadType, + }; + } + + if (BigInt(info.amount) !== BigInt(depositAmount)) { + return { isValid: false, invalidReason: Errors.ErrEip2612AmountMismatch }; + } + + return { isValid: true }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts new file mode 100644 index 0000000..4f7c124 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit.ts @@ -0,0 +1,691 @@ +import { + FacilitatorContext, + PaymentPayload, + PaymentRequirements, + VerifyResponse, + SettleResponse, +} from "@x402/core/types"; +import { getAddress, parseErc6492Signature, isAddressEqual } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { TransactionRequest } from "../../exact/extensions"; +import { BatchSettlementAssetTransferMethod, BatchSettlementDepositPayload } from "../types"; +import { batchSettlementABI, erc20BalanceOfABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { getEvmChainId } from "../../utils"; +import { multicall } from "../../multicall"; +import * as Errors from "../errors"; +import { + readChannelState, + toContractChannelConfig, + validateChannelConfig, + verifyBatchSettlementVoucherTypedData, +} from "./utils"; +import { + buildEip3009DepositCollectorData, + getEip3009DepositCollectorAddress, + verifyEip3009DepositAuthorization, + type Erc3009CounterfactualDeployment, +} from "./deposit-eip3009"; +import { + buildDepositTransaction, + getPermit2DepositCollectorAddress, + resolvePermit2DepositBranch, + verifyPermit2DepositAuthorization, +} from "./deposit-permit2"; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** + * Verifies a deposit payload (authorization + voucher) without executing any + * onchain transaction. + * + * Performs the following validations: + * - Token in channelConfig matches the payment requirements asset. + * - Deposit authorization is valid for the selected transfer method. + * - Accompanying voucher signature is valid (ECDSA or ERC-1271). + * - Payer has sufficient token balance for the deposit. + * - Resulting `maxClaimableAmount` does not exceed effective balance (existing + deposit). + * + * @param signer - Facilitator signer for onchain reads and signature verification. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - The full deposit payload including channelConfig, amount, authorization, and voucher. + * @param requirements - Server payment requirements (asset, EIP-712 domain info, timeout, etc.). + * @param context - Optional facilitator extension context. + * @param allowedFactories - Allowlisted ERC-6492 factory addresses for counterfactual deposits. + * @returns A {@link VerifyResponse} with channel state in `extra` on success. + */ +export async function verifyDeposit( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + allowedFactories: string[] = [], +): Promise { + const payer = payload.channelConfig.payer; + const chainId = getEvmChainId(requirements.network); + const configErr = validateChannelConfig( + payload.channelConfig, + payload.voucher.channelId, + requirements, + ); + if (configErr) { + return { isValid: false, invalidReason: configErr, payer }; + } + + const transferMethod = resolveDepositTransferMethod(payload, requirements); + if (transferMethod === "permit2" && !payload.deposit.authorization.permit2Authorization) { + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType, payer }; + } + + // erc3009Counterfactual is non-null when the ERC-3009 deposit is from an undeployed + // ERC-6492 wallet with an allowlisted factory; its inner signature is validated by the + // deploy+deposit simulation below rather than a direct (no-code) signature check. + let erc3009Counterfactual: Erc3009CounterfactualDeployment | null = null; + if (transferMethod === "permit2") { + const methodErr = await verifyPermit2DepositAuthorization( + signer, + payment, + payload, + requirements, + chainId, + context, + ); + if (methodErr) { + return methodErr; + } + } else { + const result = await verifyEip3009DepositAuthorization( + signer, + payload, + requirements, + chainId, + allowedFactories, + ); + if (result.response) { + return result.response; + } + erc3009Counterfactual = result.counterfactual; + } + + const shared = await verifySharedDepositState(signer, payload, requirements); + if (!shared.ok) { + return shared.response; + } + + const { depositAmount, chBalance, chTotalClaimed, wdInitiatedAt, refundNonceVal } = shared; + + const execution = await resolveDepositExecution(signer, payment, payload, requirements, context); + if ("isValid" in execution) { + return execution; + } + + if (erc3009Counterfactual) { + // Counterfactual ERC-6492 wallet: the payer has no code yet, so a plain deposit() + // eth_call would revert (no code → isValidSignature reverts). Simulate factory-deploy + + // deposit atomically in one Multicall3 eth_call so the inner signature is validated + // against the just-deployed wallet — mirroring how settle deploys then deposits. + const simulationSucceeded = await simulateCounterfactualErc3009Deposit( + signer, + erc3009Counterfactual, + payload, + depositAmount, + execution, + ); + if (!simulationSucceeded) { + return { isValid: false, invalidReason: Errors.ErrDepositSimulationFailed, payer }; + } + } else if (!execution.skipDirectSimulation) { + try { + await signer.readContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + depositAmount, + execution.collector, + execution.collectorData, + ], + }); + } catch (e) { + return { + isValid: false, + invalidReason: Errors.ErrDepositSimulationFailed, + invalidMessage: e instanceof Error ? e.message : String(e), + payer, + }; + } + } + + return { + isValid: true, + payer, + extra: { + channelId: payload.voucher.channelId, + balance: chBalance.toString(), + totalClaimed: chTotalClaimed.toString(), + withdrawRequestedAt: Number(wdInitiatedAt), + refundNonce: refundNonceVal.toString(), + }, + }; +} + +/** + * Verifies channel, voucher, balance, and cumulative amount invariants. + * + * @param signer - Facilitator signer for reads and voucher verification. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @returns Shared channel state on success, or a verification failure. + */ +async function verifySharedDepositState( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, +): Promise< + | { + ok: true; + chainId: number; + depositAmount: bigint; + payer: `0x${string}`; + chBalance: bigint; + chTotalClaimed: bigint; + wdInitiatedAt: bigint; + refundNonceVal: bigint; + } + | { ok: false; response: VerifyResponse } +> { + const { deposit, voucher } = payload; + const config = payload.channelConfig; + const payer = config.payer; + const chainId = getEvmChainId(requirements.network); + + const configErr = validateChannelConfig(config, voucher.channelId, requirements); + if (configErr) { + return { ok: false, response: { isValid: false, invalidReason: configErr, payer } }; + } + + const voucherOk = await verifyBatchSettlementVoucherTypedData( + signer, + { + channelId: voucher.channelId, + maxClaimableAmount: voucher.maxClaimableAmount, + payerAuthorizer: config.payerAuthorizer, + payer: config.payer, + signature: voucher.signature, + }, + chainId, + ); + if (!voucherOk) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrInvalidVoucherSignature, payer }, + }; + } + + const mcResults = await multicall(signer.readContract.bind(signer), [ + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "channels", + args: [voucher.channelId], + }, + { + address: getAddress(requirements.asset), + abi: erc20BalanceOfABI, + functionName: "balanceOf", + args: [getAddress(payer)], + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "pendingWithdrawals", + args: [voucher.channelId], + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "refundNonce", + args: [voucher.channelId], + }, + ]); + + const [chRes, balRes, wdRes, rnRes] = mcResults; + if ( + chRes.status === "failure" || + balRes.status === "failure" || + wdRes.status === "failure" || + rnRes.status === "failure" + ) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrRpcReadFailed, payer }, + }; + } + + const [chBalance, chTotalClaimed] = chRes.result as [bigint, bigint]; + const payerBalance = balRes.result as bigint; + const [, wdInitiatedAt] = wdRes.result as [bigint, bigint]; + const refundNonceVal = rnRes.result as bigint; + const depositAmount = BigInt(deposit.amount); + + if (payerBalance < depositAmount) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrInsufficientBalance, payer }, + }; + } + + const effectiveBalance = chBalance + depositAmount; + const maxClaimableAmount = BigInt(voucher.maxClaimableAmount); + + if (maxClaimableAmount > effectiveBalance) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrCumulativeExceedsBalance, payer }, + }; + } + + if (maxClaimableAmount <= chTotalClaimed) { + return { + ok: false, + response: { isValid: false, invalidReason: Errors.ErrCumulativeAmountBelowClaimed, payer }, + }; + } + + return { + ok: true, + chainId, + depositAmount, + payer, + chBalance, + chTotalClaimed, + wdInitiatedAt, + refundNonceVal, + }; +} + +/** + * Executes a deposit onchain through the collector for the selected transfer method. + * + * The deposit is first verified via {@link verifyDeposit}; if invalid the returned + * {@link SettleResponse} will have `success: false` with the verification reason. + * + * @param signer - Facilitator signer used to submit the onchain transaction. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - The deposit payload (channelConfig, amount, authorization, voucher). + * @param requirements - Server payment requirements. + * @param context - Optional facilitator extension context. + * @param dataSuffix - Optional hex suffix appended to the deposit transaction. + * @param allowedFactories - Allowlisted ERC-6492 factory addresses for counterfactual deposits. + * @returns A {@link SettleResponse} with the transaction hash and updated channel state in `extra`. + */ +export async function settleDeposit( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + dataSuffix?: `0x${string}`, + allowedFactories: string[] = [], +): Promise { + const { deposit, voucher } = payload; + const config = payload.channelConfig; + const payer = config.payer; + + const verified = await verifyDeposit( + signer, + payment, + payload, + requirements, + context, + allowedFactories, + ); + if (!verified.isValid) { + const reason = verified.invalidReason ?? Errors.ErrInvalidPayloadType; + return { + success: false, + errorReason: reason, + errorMessage: verified.invalidMessage ?? reason, + transaction: "", + network: requirements.network, + payer: verified.payer, + }; + } + + try { + const execution = await resolveDepositExecution( + signer, + payment, + payload, + requirements, + context, + ); + if ("isValid" in execution) { + const reason = execution.invalidReason ?? Errors.ErrInvalidPayloadType; + return { + success: false, + errorReason: reason, + errorMessage: execution.invalidMessage ?? reason, + transaction: "", + network: requirements.network, + payer: execution.payer, + }; + } + + // ERC-6492 counterfactual deposit: deploy the undeployed wallet (gated by the factory + // allowlist) before the deposit, then simulate with the inner signature to catch wallets + // whose validator is installed lazily. + if (resolveDepositTransferMethod(payload, requirements) === "eip3009") { + const deployErr = await deployErc3009CounterfactualIfNeeded( + signer, + payload, + requirements, + allowedFactories, + ); + if (deployErr) { + return deployErr; + } + } + + const depositTx = buildDepositTransaction(payload, execution.collectorData, dataSuffix); + + const tx = + execution.kind === "erc20Approval" + ? ( + await execution.extensionSigner.sendTransactions([ + execution.signedTransaction, + depositTx, + ]) + )[1] + : await signer.writeContract({ + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(config), + BigInt(deposit.amount), + execution.collector, + execution.collectorData, + ], + dataSuffix, + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrDepositTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network: requirements.network, + payer, + }; + } + + const optimisticExtra = { + channelState: { + channelId: voucher.channelId, + balance: ( + BigInt(String(verified.extra?.balance ?? "0")) + BigInt(deposit.amount) + ).toString(), + totalClaimed: String(verified.extra?.totalClaimed ?? "0"), + withdrawRequestedAt: Number(verified.extra?.withdrawRequestedAt ?? 0), + refundNonce: String(verified.extra?.refundNonce ?? "0"), + }, + }; + + // Poll the RPC until it reflects the just-confirmed deposit, so subsequent verify reads are guaranteed to see this balance + const expectedMinBalance = BigInt(optimisticExtra.channelState.balance); + const rpcDeadline = Date.now() + 2_000; + let postState = await readChannelState(signer, voucher.channelId); + while (postState.balance < expectedMinBalance && Date.now() < rpcDeadline) { + await new Promise(resolve => setTimeout(resolve, 150)); + postState = await readChannelState(signer, voucher.channelId); + } + + const rpcCaughtUp = postState.balance >= expectedMinBalance; + + return { + success: true, + transaction: tx, + network: requirements.network, + payer, + amount: deposit.amount, + extra: rpcCaughtUp + ? { + ...optimisticExtra, + channelState: { + channelId: voucher.channelId, + balance: postState.balance.toString(), + totalClaimed: postState.totalClaimed.toString(), + withdrawRequestedAt: postState.withdrawRequestedAt, + refundNonce: postState.refundNonce.toString(), + }, + } + : optimisticExtra, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrDepositTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network: requirements.network, + payer, + }; + } +} + +type DepositExecution = + | { + kind: "direct"; + collector: `0x${string}`; + collectorData: `0x${string}`; + skipDirectSimulation?: boolean; + } + | { + kind: "erc20Approval"; + collector: `0x${string}`; + collectorData: `0x${string}`; + signedTransaction: `0x${string}`; + extensionSigner: { + sendTransactions(transactions: TransactionRequest[]): Promise<`0x${string}`[]>; + }; + skipDirectSimulation: true; + }; + +/** + * Resolves the collector address and collector data for a deposit payload. + * + * @param signer - Facilitator signer for Permit2 allowance reads. + * @param payment - Full payment envelope containing optional extensions. + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @param context - Optional facilitator extension context. + * @returns Execution details, or a verification failure response. + */ +async function resolveDepositExecution( + signer: FacilitatorEvmSigner, + payment: PaymentPayload, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, +): Promise { + const transferMethod = resolveDepositTransferMethod(payload, requirements); + if (transferMethod === "eip3009") { + // collectorData carries the inner signature (ERC-6492 wrapper stripped). For a deployed + // wallet the direct deposit() simulation routes to ERC-1271; for an undeployed + // counterfactual wallet verifyDeposit runs the deploy+deposit Multicall3 simulation + // instead (see simulateCounterfactualErc3009Deposit). + return { + kind: "direct", + collector: getEip3009DepositCollectorAddress(), + collectorData: buildEip3009DepositCollectorData(payload), + }; + } + + const branch = await resolvePermit2DepositBranch(signer, payment, payload, requirements, context); + if ("isValid" in branch) { + return branch; + } + + if (branch.kind === "erc20Approval") { + return { + kind: "erc20Approval", + collector: getPermit2DepositCollectorAddress(), + collectorData: branch.collectorData, + signedTransaction: branch.signedTransaction, + extensionSigner: branch.extensionSigner, + skipDirectSimulation: true, + }; + } + + return { + kind: "direct", + collector: getPermit2DepositCollectorAddress(), + collectorData: branch.collectorData, + }; +} + +/** + * Simulates the factory deploy + deposit atomically via a single Multicall3 eth_call. + * + * The deposit succeeds only if, after the wallet is deployed in the first sub-call, its + * isValidSignature accepts the inner ERC-3009 signature carried by the (already-stripped) + * collector data. Mirrors the Go/Python `simulateCounterfactualErc3009Deposit`. + * + * @param signer - Facilitator signer used for the Multicall3 read. + * @param counterfactual - Factory + calldata for the undeployed ERC-6492 wallet. + * @param payload - Batch deposit payload (provides the channel config). + * @param depositAmount - Deposit amount in token base units. + * @param execution - Resolved deposit execution details. + * @param execution.collector - Collector contract the deposit routes through. + * @param execution.collectorData - ABI-encoded collector data carrying the inner signature. + * @returns True when the deposit sub-call would succeed against the just-deployed wallet. + */ +async function simulateCounterfactualErc3009Deposit( + signer: FacilitatorEvmSigner, + counterfactual: Erc3009CounterfactualDeployment, + payload: BatchSettlementDepositPayload, + depositAmount: bigint, + execution: { collector: `0x${string}`; collectorData: `0x${string}` }, +): Promise { + const results = await multicall(signer.readContract.bind(signer), [ + { + address: counterfactual.factory, + callData: counterfactual.factoryCalldata, + }, + { + address: getAddress(BATCH_SETTLEMENT_ADDRESS), + abi: batchSettlementABI, + functionName: "deposit", + args: [ + toContractChannelConfig(payload.channelConfig), + depositAmount, + execution.collector, + execution.collectorData, + ], + }, + ]); + return results.length >= 2 && results[1].status === "success"; +} + +/** + * Deploys an undeployed ERC-6492 wallet before an ERC-3009 deposit. + * + * Returns null when no deployment is needed (caller proceeds to deposit), or a terminal + * {@link SettleResponse} when the factory is disallowed, the deploy reverts, or the deployed + * wallet rejects the inner signature. + * + * @param signer - Facilitator signer used to deploy the wallet and simulate the deposit. + * @param payload - Batch deposit payload carrying the ERC-6492-wrapped authorization. + * @param requirements - Server payment requirements (used for the network in error responses). + * @param allowedFactories - Allowlisted ERC-6492 factory addresses. + * @returns A terminal {@link SettleResponse} on failure, or null to proceed with the deposit. + */ +async function deployErc3009CounterfactualIfNeeded( + signer: FacilitatorEvmSigner, + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, + allowedFactories: string[], +): Promise { + const config = payload.channelConfig; + const payer = config.payer; + const auth = payload.deposit.authorization.erc3009Authorization; + if (!auth) { + return null; + } + + const { address: factory, data: factoryCalldata } = parseErc6492Signature(auth.signature); + const hasDeploymentInfo = !!( + factory && + factoryCalldata && + !isAddressEqual(factory, ZERO_ADDRESS) + ); + if (!hasDeploymentInfo) { + return null; + } + + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payer }); + } catch { + code = undefined; + } + if (code && code !== "0x") { + // Already deployed — nothing to do; proceed with the standard deposit. + return null; + } + + const normalizedFactory = factory.toLowerCase(); + if (!allowedFactories.some(a => a.trim().toLowerCase() === normalizedFactory)) { + return { + success: false, + errorReason: Errors.ErrFactoryNotAllowed, + errorMessage: "factory not in eip6492AllowedFactories allowlist", + transaction: "", + network: requirements.network, + payer, + }; + } + + const deployTx = await signer.sendTransaction({ + to: factory, + data: factoryCalldata as `0x${string}`, + }); + const deployReceipt = await signer.waitForTransactionReceipt({ hash: deployTx }); + if (deployReceipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSmartWalletDeploymentFailed, + transaction: "", + network: requirements.network, + payer, + }; + } + + return null; +} + +/** + * Selects the transfer method from requirements, falling back to payload shape. + * + * @param payload - Batch deposit payload. + * @param requirements - Payment requirements for the request. + * @returns Selected batch-settlement transfer method. + */ +function resolveDepositTransferMethod( + payload: BatchSettlementDepositPayload, + requirements: PaymentRequirements, +): BatchSettlementAssetTransferMethod { + const hinted = ( + requirements.extra as { assetTransferMethod?: BatchSettlementAssetTransferMethod } + )?.assetTransferMethod; + if (hinted) { + return hinted; + } + return payload.deposit.authorization.permit2Authorization ? "permit2" : "eip3009"; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts new file mode 100644 index 0000000..735a2d8 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/index.ts @@ -0,0 +1,2 @@ +export { BatchSettlementEvmScheme } from "./scheme"; +export type { BatchSettlementEvmSchemeConfig } from "./scheme"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts new file mode 100644 index 0000000..ab273bf --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/refund.ts @@ -0,0 +1,385 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { encodeFunctionData, getAddress } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import type { + AuthorizerSigner, + BatchSettlementEnrichedRefundPayload, + ChannelState, +} from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import { computeChannelId } from "../utils"; +import { signClaimBatch, signRefund } from "../authorizerSigner"; +import * as Errors from "../errors"; +import { buildVoucherClaimArgs } from "./claim"; +import { readChannelState, toContractChannelConfig } from "./utils"; + +type RefundSettlementExtra = { + channelState: { + channelId: `0x${string}`; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: string; + }; +}; + +type RefundSettlementDetails = { + amount: string; + extra: RefundSettlementExtra; +}; + +const REFUND_STATE_POLL_MS = 2_000; +const REFUND_STATE_POLL_INTERVAL_MS = 150; + +/** + * Computes the token amount that `refundWithSignature` would transfer after any + * bundled claims are applied. + * + * @param payload - Refund payload containing requested refund amount and claims. + * @param preState - Onchain channel state before the refund transaction. + * @param channelId - Channel being refunded. + * @param network - Network identifier used to compute claim channel ids. + * @returns Refund amount if it can be determined, or `null` when claim data should be left to simulation. + */ +function getRefundableAmount( + payload: BatchSettlementEnrichedRefundPayload, + preState: ChannelState, + channelId: `0x${string}`, + network: string, +): bigint | null { + const postClaimTotalClaimed = payload.claims.reduce((max, claim) => { + const claimChannelId = computeChannelId(claim.voucher.channel, network); + if (claimChannelId.toLowerCase() !== channelId.toLowerCase()) { + return max; + } + + const totalClaimed = BigInt(claim.totalClaimed); + return totalClaimed > max ? totalClaimed : max; + }, preState.totalClaimed); + + if (postClaimTotalClaimed > preState.balance) { + return null; + } + + const requestedAmount = BigInt(payload.amount); + if (requestedAmount === 0n) { + return null; + } + + const available = preState.balance - postClaimTotalClaimed; + return requestedAmount > available ? available : requestedAmount; +} + +/** + * Builds facilitator-owned response details for a refund settlement after applying the refund amount. + * + * @param payload - Refund payload containing claims and amount. + * @param channelId - Canonical channel id for the refund. + * @param preState - Onchain channel state before this refund, or null if unknown. + * @returns Actual refund amount and extra fields for the settlement response. + */ +function buildRefundExtra( + payload: BatchSettlementEnrichedRefundPayload, + channelId: `0x${string}`, + preState: ChannelState | null, +): RefundSettlementDetails { + const preTotalClaimed = preState?.totalClaimed ?? 0n; + const preBalance = preState?.balance ?? 0n; + + const lastClaimTotal = + payload.claims.length > 0 + ? BigInt(payload.claims[payload.claims.length - 1].totalClaimed) + : preTotalClaimed; + const postClaimTotalClaimed = lastClaimTotal > preTotalClaimed ? lastClaimTotal : preTotalClaimed; + + const available = preBalance - postClaimTotalClaimed; + const requestedAmount = BigInt(payload.amount); + const actualRefund = requestedAmount > available ? available : requestedAmount; + + return { + amount: actualRefund.toString(), + extra: { + channelState: { + channelId, + balance: (preBalance - actualRefund).toString(), + totalClaimed: postClaimTotalClaimed.toString(), + withdrawRequestedAt: 0, + refundNonce: String((preState?.refundNonce ?? 0n) + 1n), + }, + }, + }; +} + +/** + * Reads the post-refund state when pending withdrawal state can be affected. + * + * @param signer - Facilitator signer used for onchain reads. + * @param channelId - Channel that was refunded. + * @param submittedNonce - Nonce used for this refund transaction. + * @returns Fresh channel state once the nonce advances, or `null` if RPC reads lag. + */ +async function readPostRefundState( + signer: FacilitatorEvmSigner, + channelId: `0x${string}`, + submittedNonce: string, +): Promise { + const expectedNonce = BigInt(submittedNonce) + 1n; + const deadline = Date.now() + REFUND_STATE_POLL_MS; + + do { + let state: ChannelState; + try { + state = await readChannelState(signer, channelId); + } catch { + return null; + } + if (state.refundNonce >= expectedNonce) { + return state; + } + await new Promise(resolve => setTimeout(resolve, REFUND_STATE_POLL_INTERVAL_MS)); + } while (Date.now() < deadline); + + return null; +} + +/** + * Builds refund response details from confirmed post-transaction state. + * + * @param channelId - Canonical channel id for the refund. + * @param preState - Onchain state read before the transaction. + * @param postState - Onchain state after the transaction. + * @returns Actual refund amount and extra fields for the settlement response. + */ +function buildRefundExtraFromPostState( + channelId: `0x${string}`, + preState: ChannelState, + postState: ChannelState, +): RefundSettlementDetails { + const actualRefund = + preState.balance > postState.balance ? preState.balance - postState.balance : 0n; + + return { + amount: actualRefund.toString(), + extra: { + channelState: { + channelId, + balance: postState.balance.toString(), + totalClaimed: postState.totalClaimed.toString(), + withdrawRequestedAt: postState.withdrawRequestedAt, + refundNonce: postState.refundNonce.toString(), + }, + }, + }; +} + +/** + * Executes a cooperative refund via `refundWithSignature`. + * + * When `refundAuthorizerSignature` / `claimAuthorizerSignature` are present they are used + * directly. When absent the facilitator signs the missing digests using + * `authorizerSigner`, after verifying that `config.receiverAuthorizer` matches + * `authorizerSigner.address`. + * + * If `payload.claims` is non-empty, the claim and refund are batched atomically via + * the contract's `multicall`. + * + * @param signer - Facilitator signer used to submit the onchain transactions. + * @param payload - Refund payload with optional signatures, amount, and nonce. + * @param requirements - Payment requirements for network identification. + * @param authorizerSigner - Optional dedicated key for producing EIP-712 signatures. + * When omitted, the payload must already carry the required authorizer signatures. + * @param dataSuffix - Optional hex suffix appended to the refund transaction. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeRefundWithSignature( + signer: FacilitatorEvmSigner, + payload: BatchSettlementEnrichedRefundPayload, + requirements: PaymentRequirements, + authorizerSigner: AuthorizerSigner | undefined, + dataSuffix?: `0x${string}`, +): Promise { + const network = requirements.network; + + try { + const channelId = computeChannelId(payload.channelConfig, network); + const preState = await readChannelState(signer, channelId); + const contractAddr = getAddress(BATCH_SETTLEMENT_ADDRESS); + const refundableAmount = getRefundableAmount(payload, preState, channelId, network); + + if (refundableAmount === 0n) { + return { + success: false, + errorReason: Errors.ErrRefundNoBalance, + errorMessage: "Nothing to refund", + transaction: "", + network, + }; + } + + const hasClientSig = payload.refundAuthorizerSignature !== undefined; + + if (!hasClientSig && !authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } + + if ( + !hasClientSig && + authorizerSigner && + getAddress(payload.channelConfig.receiverAuthorizer) !== getAddress(authorizerSigner.address) + ) { + return { + success: false, + errorReason: Errors.ErrAuthorizerAddressMismatch, + transaction: "", + network, + }; + } + + const refundSig = + payload.refundAuthorizerSignature ?? + (await signRefund( + authorizerSigner!, + channelId, + payload.amount, + payload.refundNonce, + network, + )); + + const refundCalldata = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + }); + + let tx: `0x${string}`; + + if (payload.claims.length > 0) { + let claimSig = payload.claimAuthorizerSignature; + if (!claimSig) { + if (!authorizerSigner) { + return { + success: false, + errorReason: Errors.ErrAuthorizerNotConfigured, + transaction: "", + network, + }; + } + claimSig = await signClaimBatch(authorizerSigner, payload.claims, network); + } + + const claimCalldata = encodeFunctionData({ + abi: batchSettlementABI, + functionName: "claimWithSignature", + args: [buildVoucherClaimArgs(payload.claims), claimSig], + }); + + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "multicall", + args: [[claimCalldata, refundCalldata]], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "multicall", + args: [[claimCalldata, refundCalldata]], + dataSuffix, + }); + } else { + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "refundWithSignature", + args: [ + toContractChannelConfig(payload.channelConfig), + BigInt(payload.amount), + BigInt(payload.refundNonce), + refundSig, + ], + dataSuffix, + }); + } + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrRefundTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + const postState = + preState && preState.withdrawRequestedAt !== 0 + ? await readPostRefundState(signer, channelId, payload.refundNonce) + : null; + const refundDetails = + preState && postState + ? buildRefundExtraFromPostState(channelId, preState, postState) + : buildRefundExtra(payload, channelId, preState); + + return { + success: true, + transaction: tx, + network, + payer: payload.channelConfig.payer, + amount: refundDetails.amount, + extra: refundDetails.extra, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRefundTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts new file mode 100644 index 0000000..2479a5d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/scheme.ts @@ -0,0 +1,220 @@ +import { + PaymentPayload, + PaymentRequirements, + SchemeNetworkFacilitator, + FacilitatorContext, + SettleResponse, + VerifyResponse, +} from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BATCH_SETTLEMENT_SCHEME } from "../constants"; +import { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementRefundPayload, + isBatchSettlementEnrichedRefundPayload, +} from "../types"; +import type { AuthorizerSigner } from "../types"; +import { verifyDeposit, settleDeposit } from "./deposit"; +import { verifyVoucher } from "./voucher"; +import { executeClaimWithSignature } from "./claim"; +import { executeSettle } from "./settle"; +import { executeRefundWithSignature } from "./refund"; +import { resolveDataSuffix } from "../../shared/extensions"; +import * as Errors from "../errors"; + +export interface BatchSettlementEvmSchemeConfig { + /** + * Allowlist of factory contract addresses (hex strings, case-insensitive) the facilitator + * will call to deploy an undeployed (ERC-6492 counterfactual) smart wallet before an + * ERC-3009 deposit. An empty or omitted list denies all factory deployment (feature + * disabled by default). + * + * @default [] + */ + eip6492AllowedFactories?: string[]; +} + +/** + * Facilitator-side implementation of the `batch-settlement` scheme for EVM networks. + * + * Routes incoming verify/settle requests to the appropriate handler based on payload + * type (deposit, voucher, claim, settle, refund). + */ +export class BatchSettlementEvmScheme implements SchemeNetworkFacilitator { + readonly scheme = BATCH_SETTLEMENT_SCHEME; + readonly caipFamily = "eip155:*"; + private readonly config: Required; + + /** + * Creates a facilitator scheme for verifying and settling batch-settlement payments. + * + * @param signer - Facilitator EVM signer(s) used for tx submission and onchain reads. + * @param authorizerSigner - Optional dedicated key that provides EIP-712 signatures for + * `claimWithSignature` / `refundWithSignature`. When provided, the facilitator advertises + * its address as `receiverAuthorizer` in `/supported` and signs missing authorizer + * signatures using this key when the server omits them. A facilitator that advertises a + * `receiverAuthorizer` for servers to delegate to must authenticate refund requests (see the + * spec); when no such mechanism exists, omit this signer so no `receiverAuthorizer` is + * advertised and servers supply their own signatures. + * @param config - Optional configuration (e.g. ERC-6492 factory allowlist). + */ + constructor( + private readonly signer: FacilitatorEvmSigner, + private readonly authorizerSigner?: AuthorizerSigner, + config?: BatchSettlementEvmSchemeConfig, + ) { + this.config = { + eip6492AllowedFactories: config?.eip6492AllowedFactories ?? [], + }; + } + + /** + * Returns facilitator-specific extra fields to be merged into payment requirements. + * + * Exposes the configured `receiverAuthorizer` address so the server and client can + * embed it in `ChannelConfig`. Returns `undefined` when no authorizer signer is + * configured, signalling that servers must supply their own authorizer signatures. + * + * @param _ - Network identifier (unused). + * @returns Extra fields containing `receiverAuthorizer`, or `undefined`. + */ + getExtra(_: string): { receiverAuthorizer: `0x${string}` } | undefined { + if (!this.authorizerSigner) { + return undefined; + } + return { receiverAuthorizer: this.authorizerSigner.address }; + } + + /** + * Returns all facilitator signer addresses available for the given network. + * + * @param _ - Network identifier (unused). + * @returns Array of hex addresses. + */ + getSigners(_: string): `0x${string}`[] { + return [...this.signer.getAddresses()]; + } + + /** + * Verifies a payment payload (deposit or voucher) without executing settlement. + * + * @param payload - The x402 payment payload envelope. + * @param requirements - Server payment requirements (scheme, network, asset, amount). + * @param context - Optional facilitator extension context. + * @param _ - Payment required extensions (unused; reserved for interface parity) + * @returns A {@link VerifyResponse} indicating validity with payer and channel state in `extra`. + */ + async verify( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + _?: Record, + ): Promise { + const rawPayload = payload.payload; + + if ( + payload.accepted.scheme !== BATCH_SETTLEMENT_SCHEME || + requirements.scheme !== BATCH_SETTLEMENT_SCHEME + ) { + return { isValid: false, invalidReason: Errors.ErrInvalidScheme }; + } + + if (payload.accepted.network !== requirements.network) { + return { isValid: false, invalidReason: Errors.ErrNetworkMismatch }; + } + + if (isBatchSettlementDepositPayload(rawPayload)) { + return verifyDeposit( + this.signer, + payload, + rawPayload, + requirements, + context, + this.config.eip6492AllowedFactories, + ); + } + + if (isBatchSettlementVoucherPayload(rawPayload)) { + return verifyVoucher(this.signer, rawPayload, requirements, rawPayload.channelConfig); + } + + if (isBatchSettlementRefundPayload(rawPayload)) { + return verifyVoucher(this.signer, rawPayload, requirements, rawPayload.channelConfig); + } + + return { isValid: false, invalidReason: Errors.ErrInvalidPayloadType }; + } + + /** + * Executes settlement for a payment payload. + * + * Dispatches to the correct handler based on payload settle action: + * - `deposit` → onchain `deposit(config, amount, collector, collectorData)` + * - `claim` → onchain `claimWithSignature(VoucherClaim[], bytes)` + * - `settle` → onchain `settle(receiver, token)` + * - `refund` → optional claim + onchain `refundWithSignature(config, amount, nonce, sig)` + * + * @param payload - The x402 payment payload envelope. + * @param requirements - Server payment requirements. + * @param context - Optional facilitator extension context. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ + async settle( + payload: PaymentPayload, + requirements: PaymentRequirements, + context?: FacilitatorContext, + ): Promise { + const rawPayload = payload.payload; + + const dataSuffix = await resolveDataSuffix(context, { + paymentPayload: payload, + paymentRequirements: requirements, + }); + + if (isBatchSettlementDepositPayload(rawPayload)) { + return settleDeposit( + this.signer, + payload, + rawPayload, + requirements, + context, + dataSuffix, + this.config.eip6492AllowedFactories, + ); + } + + if (isBatchSettlementClaimPayload(rawPayload)) { + return executeClaimWithSignature( + this.signer, + rawPayload, + requirements, + this.authorizerSigner, + dataSuffix, + ); + } + + if (isBatchSettlementEnrichedRefundPayload(rawPayload)) { + return executeRefundWithSignature( + this.signer, + rawPayload, + requirements, + this.authorizerSigner, + dataSuffix, + ); + } + + if (isBatchSettlementSettlePayload(rawPayload)) { + return executeSettle(this.signer, rawPayload, requirements, dataSuffix); + } + + return { + success: false, + errorReason: Errors.ErrInvalidPayloadType, + transaction: "", + network: requirements.network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts new file mode 100644 index 0000000..ab64000 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/settle.ts @@ -0,0 +1,146 @@ +import { SettleResponse, PaymentRequirements } from "@x402/core/types"; +import { getAddress, isAddressEqual, parseEventLogs } from "viem"; +import { FacilitatorEvmSigner } from "../../signer"; +import { BatchSettlementSettlePayload } from "../types"; +import { batchSettlementABI } from "../abi"; +import { BATCH_SETTLEMENT_ADDRESS } from "../constants"; +import * as Errors from "../errors"; + +/** + * Explicit gas limit for the `settle` transaction. + * + * `settle` auto-estimation is unsafe: the on-chain `settle` is bimodal — it + * early-returns (~25.5k gas) when `totalClaimed == totalSettled`, and performs + * an SSTORE plus an ERC-20 transfer (~57k gas) otherwise. The `eth_estimateGas` + * viem runs for `writeContract` is an independent RPC call that can resolve + * against a node whose state has not yet caught up to the just-mined `claim`; + * it then estimates the early-return path and the transaction is broadcast + * under-gassed, reverting out of gas once the claim is visible. + * + * `settle`'s cost is bounded (one SSTORE + one transfer, no loop — it does not + * scale with voucher or channel count), so a fixed limit is correct here. This + * value leaves roughly a 2x margin over the observed transfer-path cost. + * `deposit-permit2.ts` uses the same explicit-gas pattern. + */ +const SETTLE_GAS_LIMIT = 120_000n; + +/** + * Transfers claimed funds from the contract. + * + * This should be called after one or more `claim()` transactions have updated the + * receiver's `totalClaimed` accounting onchain. + * + * @param signer - Facilitator signer used to submit the settlement transaction. + * @param payload - Settle payload containing the receiver address and token address. + * @param requirements - Payment requirements for network identification. + * @param dataSuffix - Optional hex suffix appended to the settlement transaction. + * @returns A {@link SettleResponse} with the transaction hash on success. + */ +export async function executeSettle( + signer: FacilitatorEvmSigner, + payload: BatchSettlementSettlePayload, + requirements: PaymentRequirements, + dataSuffix?: `0x${string}`, +): Promise { + const network = requirements.network; + const contractAddr = getAddress(BATCH_SETTLEMENT_ADDRESS); + const receiver = getAddress(payload.receiver); + const token = getAddress(payload.token); + + // Check if there is anything to settle + try { + const [totalClaimed, totalSettled] = (await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "receivers", + args: [receiver, token], + })) as readonly [bigint, bigint]; + + if (totalClaimed <= totalSettled) { + return { + success: false, + errorReason: Errors.ErrNothingToSettle, + errorMessage: "nothing to settle for receiver and token", + transaction: "", + network, + }; + } + } catch (e) { + return { + success: false, + errorReason: Errors.ErrRpcReadFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + // Simulate the settle transaction + try { + await signer.readContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "settle", + args: [receiver, token], + }); + } catch (e) { + return { + success: false, + errorReason: Errors.ErrSettleSimulationFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } + + try { + const tx = await signer.writeContract({ + address: contractAddr, + abi: batchSettlementABI, + functionName: "settle", + args: [receiver, token], + gas: SETTLE_GAS_LIMIT, + dataSuffix, + }); + + const receipt = await signer.waitForTransactionReceipt({ hash: tx }); + + if (receipt.status !== "success") { + return { + success: false, + errorReason: Errors.ErrSettleTransactionFailed, + errorMessage: `transaction reverted (receipt status ${receipt.status})`, + transaction: tx, + network, + }; + } + + let amount = ""; + if (receipt.logs) { + const logs = parseEventLogs({ + abi: batchSettlementABI, + eventName: "Settled", + logs: receipt.logs.filter(log => isAddressEqual(log.address, contractAddr)), + }); + const settledLog = logs.find( + log => isAddressEqual(log.args.receiver, receiver) && isAddressEqual(log.args.token, token), + ); + amount = settledLog?.args.amount.toString() ?? "0"; + } + + return { + success: true, + transaction: tx, + network, + amount, + }; + } catch (e) { + return { + success: false, + errorReason: Errors.ErrSettleTransactionFailed, + errorMessage: e instanceof Error ? e.message : String(e), + transaction: "", + network, + }; + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts new file mode 100644 index 0000000..c7b560f --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/utils.ts @@ -0,0 +1,233 @@ +import { getAddress, hashTypedData, recoverAddress, isAddressEqual } from "viem"; +import { verifyTypedDataSignature } from "../../shared/verifySignature"; +import type { PaymentRequirements } from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { multicall } from "../../multicall"; +import { + BATCH_SETTLEMENT_ADDRESS, + MIN_WITHDRAW_DELAY, + MAX_WITHDRAW_DELAY, + voucherTypes, +} from "../constants"; +import { batchSettlementABI } from "../abi"; +import type { + BatchSettlementPaymentRequirementsExtra, + ChannelConfig, + ChannelState, +} from "../types"; +import { computeChannelId, getBatchSettlementEip712Domain } from "../utils"; +import * as Errors from "../errors"; + +/** + * Normalises a {@link ChannelConfig} into the checksummed-address tuple expected by the + * batch-settlement contract's `deposit` / `refundWithSignature` / `claimWithSignature` calls. + * + * @param config - In-memory channel configuration. + * @returns Channel config tuple with all address fields checksummed via `getAddress`. + */ +export function toContractChannelConfig(config: ChannelConfig) { + return { + payer: getAddress(config.payer), + payerAuthorizer: getAddress(config.payerAuthorizer), + receiver: getAddress(config.receiver), + receiverAuthorizer: getAddress(config.receiverAuthorizer), + token: getAddress(config.token), + withdrawDelay: config.withdrawDelay, + salt: config.salt, + }; +} + +/** + * Case-insensitive comparison of two channel id hex strings. + * + * @param a - First channel id. + * @param b - Second channel id (may be any unknown value). + * @returns `true` when both ids refer to the same channel. + */ +export function channelIdsEqual(a: `0x${string}`, b: unknown): boolean { + if (typeof b !== "string" || b.length === 0) return false; + const norm = (x: string) => { + let s = x.toLowerCase(); + if (s.startsWith("0x")) s = s.slice(2); + return `0x${s}`; + }; + return norm(a) === norm(b); +} + +/** + * Validates the time window of an ERC-3009 `ReceiveWithAuthorization`. + * + * @param validAfter - Earliest unix timestamp the authorization is valid (in seconds). + * @param validBefore - Latest unix timestamp before which the authorization is valid. + * @returns An error code string if the time window is invalid, otherwise `undefined`. + */ +export function erc3009AuthorizationTimeInvalidReason( + validAfter: bigint, + validBefore: bigint, +): string | undefined { + const now = Math.floor(Date.now() / 1000); + if (validBefore < BigInt(now + 6)) return Errors.ErrValidBeforeExpired; + if (validAfter > BigInt(now)) return Errors.ErrValidAfterInFuture; + return undefined; +} + +/** + * Dual-path voucher signature verification. + * + * When `payerAuthorizer` is a non-zero address, the signature is verified off-chain via + * ECDSA recovery against that address (no RPC call). When `payerAuthorizer` is `address(0)`, + * verification falls back to an ERC-1271 `isValidSignature` call against the payer contract + * (smart-wallet path). + * + * @param signer - Facilitator signer providing `verifyTypedData` (may issue RPC for ERC-1271). + * @param params - Voucher fields and authorizer addresses needed for verification. + * @param params.channelId - EIP-712 voucher channel id (`bytes32` hex). + * @param params.maxClaimableAmount - Max cumulative claimable amount as a decimal string. + * @param params.payerAuthorizer - Address that signed the voucher; zero address selects ERC-1271 verification. + * @param params.payer - Payer contract address (used for ERC-1271). + * @param params.signature - EIP-712 signature bytes over the voucher. + * @param chainId - Numeric EVM chain id for the EIP-712 domain. + * @returns `true` when the voucher signature is valid. + */ +export async function verifyBatchSettlementVoucherTypedData( + signer: FacilitatorEvmSigner, + params: { + channelId: `0x${string}`; + maxClaimableAmount: string; + payerAuthorizer: `0x${string}`; + payer: `0x${string}`; + signature: `0x${string}`; + }, + chainId: number, +): Promise { + const domain = getBatchSettlementEip712Domain(chainId); + const message = { + channelId: params.channelId, + maxClaimableAmount: BigInt(params.maxClaimableAmount), + }; + + const zeroAddress = "0x0000000000000000000000000000000000000000"; + + if (params.payerAuthorizer !== zeroAddress) { + // on-chain x402BatchSettlement uses ECDSA.recoverCalldata — pure ecrecover, + // no code check, no EIP-1271. Use recoverAddress directly so there is no + // ambiguity: this path never issues an RPC call regardless of address state. + try { + const digest = hashTypedData({ + domain, + types: voucherTypes, + primaryType: "Voucher", + message, + }); + const recovered = await recoverAddress({ + hash: digest, + signature: params.signature as `0x${string}`, + }); + return isAddressEqual(recovered, getAddress(params.payerAuthorizer)); + } catch { + return false; + } + } + + // payerAuthorizer == 0 path: x402BatchSettlement._processVoucherClaim falls + // back to OpenZeppelin's SignatureChecker.isValidSignatureNow(payer, …) which + // routes by signer.code.length (ECDSA for EOAs, strict EIP-1271 for contracts + // including 7702-delegated EOAs). Mirror that exactly — no ECDSA fallback + // for addresses with code. + return verifyTypedDataSignature(signer, { + address: getAddress(params.payer), + domain, + types: voucherTypes, + primaryType: "Voucher", + message, + signature: params.signature, + }); +} + +/** + * Validates that a {@link ChannelConfig} is consistent with the claimed `channelId` and + * the server's {@link PaymentRequirements}. + * + * @param config - The channel configuration from the payload. + * @param channelId - The `channelId` claimed in the payload. + * @param requirements - Server payment requirements to cross-check against. + * @returns An error code string if validation fails, otherwise `undefined`. + */ +export function validateChannelConfig( + config: ChannelConfig, + channelId: `0x${string}`, + requirements: PaymentRequirements, +): string | undefined { + const computedId = computeChannelId(config, requirements.network); + if (computedId.toLowerCase() !== channelId.toLowerCase()) { + return Errors.ErrChannelIdMismatch; + } + + if (getAddress(config.receiver) !== getAddress(requirements.payTo)) { + return Errors.ErrReceiverMismatch; + } + + const extra = requirements.extra as Partial | undefined; + const requiredReceiverAuthorizer = extra?.receiverAuthorizer; + + if ( + !requiredReceiverAuthorizer || + getAddress(requiredReceiverAuthorizer) === "0x0000000000000000000000000000000000000000" || + getAddress(config.receiverAuthorizer) !== getAddress(requiredReceiverAuthorizer) + ) { + return Errors.ErrReceiverAuthorizerMismatch; + } + + if (getAddress(config.token) !== getAddress(requirements.asset)) { + return Errors.ErrTokenMismatch; + } + + if (extra?.withdrawDelay !== undefined && config.withdrawDelay !== Number(extra.withdrawDelay)) { + return Errors.ErrWithdrawDelayMismatch; + } + + if (config.withdrawDelay < MIN_WITHDRAW_DELAY || config.withdrawDelay > MAX_WITHDRAW_DELAY) { + return Errors.ErrWithdrawDelayOutOfRange; + } + + return undefined; +} + +/** + * Reads onchain channel state via a 3-call multicall: + * `channels(channelId)`, `pendingWithdrawals(channelId)`, `refundNonce(channelId)`. + * + * Throws when any sub-call fails so callers can distinguish RPC failures + * from missing channels (which return zero balance/totalClaimed/refundNonce). + * + * @param signer - Facilitator signer for onchain reads. + * @param channelId - The `bytes32` channel id. + * @returns Fresh {@link ChannelState}. + */ +export async function readChannelState( + signer: FacilitatorEvmSigner, + channelId: `0x${string}`, +): Promise { + const target = getAddress(BATCH_SETTLEMENT_ADDRESS); + const mcResults = await multicall(signer.readContract.bind(signer), [ + { address: target, abi: batchSettlementABI, functionName: "channels", args: [channelId] }, + { + address: target, + abi: batchSettlementABI, + functionName: "pendingWithdrawals", + args: [channelId], + }, + { address: target, abi: batchSettlementABI, functionName: "refundNonce", args: [channelId] }, + ]); + + const [chRes, wdRes, rnRes] = mcResults; + if (chRes.status === "failure" || wdRes.status === "failure" || rnRes.status === "failure") { + throw new Error(`${Errors.ErrRpcReadFailed}: multicall returned failure for ${channelId}`); + } + + const [balance, totalClaimed] = chRes.result as [bigint, bigint]; + const [, wdInitiatedAt] = wdRes.result as [bigint, bigint]; + const refundNonce = rnRes.result as bigint; + + return { balance, totalClaimed, withdrawRequestedAt: Number(wdInitiatedAt), refundNonce }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts new file mode 100644 index 0000000..abee9f4 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/voucher.ts @@ -0,0 +1,98 @@ +import { PaymentRequirements, VerifyResponse } from "@x402/core/types"; +import { FacilitatorEvmSigner } from "../../signer"; +import { + BatchSettlementRefundPayload, + BatchSettlementVoucherPayload, + ChannelConfig, +} from "../types"; +import { getEvmChainId } from "../../utils"; +import * as Errors from "../errors"; +import { + validateChannelConfig, + verifyBatchSettlementVoucherTypedData, + readChannelState, +} from "./utils"; + +/** + * Verifies a cumulative voucher payload against onchain channel state. + * + * @param signer - Facilitator signer used for onchain reads and signature verification. + * @param payload - Voucher or refund payload with signed voucher fields. + * @param requirements - Server payment requirements (asset, network, amount). + * @param channelConfig - Reconstructed channel configuration for the payer/receiver pair. + * @returns A {@link VerifyResponse} indicating validity and returning channel state in `extra`. + */ +export async function verifyVoucher( + signer: FacilitatorEvmSigner, + payload: BatchSettlementVoucherPayload | BatchSettlementRefundPayload, + requirements: PaymentRequirements, + channelConfig: ChannelConfig, +): Promise { + const { voucher } = payload; + const channelId = voucher.channelId; + const chainId = getEvmChainId(requirements.network); + + const configErr = validateChannelConfig(channelConfig, channelId, requirements); + if (configErr) { + return { isValid: false, invalidReason: configErr, payer: channelConfig.payer }; + } + + const voucherOk = await verifyBatchSettlementVoucherTypedData( + signer, + { + channelId, + maxClaimableAmount: voucher.maxClaimableAmount, + payerAuthorizer: channelConfig.payerAuthorizer, + payer: channelConfig.payer, + signature: voucher.signature, + }, + chainId, + ); + if (!voucherOk) { + return { + isValid: false, + invalidReason: Errors.ErrInvalidVoucherSignature, + payer: channelConfig.payer, + }; + } + + const state = await readChannelState(signer, channelId); + + if (state.balance === 0n) { + return { isValid: false, invalidReason: Errors.ErrChannelNotFound, payer: channelConfig.payer }; + } + + const maxClaimableAmount = BigInt(voucher.maxClaimableAmount); + + if (maxClaimableAmount > state.balance) { + return { + isValid: false, + invalidReason: Errors.ErrCumulativeExceedsBalance, + payer: channelConfig.payer, + }; + } + + const belowClaimed = + payload.type === "refund" + ? maxClaimableAmount < state.totalClaimed + : maxClaimableAmount <= state.totalClaimed; + if (belowClaimed) { + return { + isValid: false, + invalidReason: Errors.ErrCumulativeAmountBelowClaimed, + payer: channelConfig.payer, + }; + } + + return { + isValid: true, + payer: channelConfig.payer, + extra: { + channelId, + balance: state.balance.toString(), + totalClaimed: state.totalClaimed.toString(), + withdrawRequestedAt: state.withdrawRequestedAt, + refundNonce: state.refundNonce.toString(), + }, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts new file mode 100644 index 0000000..541e747 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/index.ts @@ -0,0 +1,2 @@ +export { BatchSettlementEvmScheme } from "./client/scheme"; +export { computeChannelId } from "./utils"; diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts new file mode 100644 index 0000000..039ac8d --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/storage-utils.ts @@ -0,0 +1,52 @@ +import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +/** + * Returns true when `err` is a Node.js `ENOENT` filesystem error (file does not exist). + * + * @param err - The thrown value to inspect. + * @returns `true` for `ENOENT`, `false` for any other value or error code. + */ +export function isNodeEnoent(err: unknown): boolean { + if (!err || typeof err !== "object" || !("code" in err)) return false; + return (err as NodeJS.ErrnoException).code === "ENOENT"; +} + +/** + * Reads a JSON file and parses it. Returns `undefined` if the file does not exist. + * Other errors (permission, malformed JSON) are rethrown. + * + * @param filePath - Path to the JSON file. + * @returns Parsed value, or `undefined` for `ENOENT`. + */ +export async function readJsonFile(filePath: string): Promise { + try { + const raw = await readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch (err: unknown) { + if (isNodeEnoent(err)) return undefined; + throw err; + } +} + +/** + * Writes JSON to `filePath` atomically (temp file in the same directory, then rename). + * Creates parent directories as needed. + * + * @param filePath - Destination file path; parent dirs are created if missing. + * @param value - JSON-serializable value to persist. + */ +export async function writeJsonAtomic(filePath: string, value: unknown): Promise { + const dir = dirname(filePath); + await mkdir(dir, { recursive: true }); + const tmp = join(dir, `.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`); + const body = `${JSON.stringify(value, null, 2)}\n`; + await writeFile(tmp, body, "utf8"); + try { + await rename(tmp, filePath); + } catch { + // On Windows, rename() onto an existing file throws EEXIST; unlink + rename is intentional. + await unlink(filePath).catch(() => {}); + await rename(tmp, filePath); + } +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts new file mode 100644 index 0000000..157a097 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/types.ts @@ -0,0 +1,288 @@ +import type { TypedData } from "viem"; + +export interface AuthorizerSigner { + address: `0x${string}`; + signTypedData(params: { + domain: Record; + types: TypedData; + primaryType: string; + message: Record; + }): Promise<`0x${string}`>; +} + +export type ChannelState = { + balance: bigint; + totalClaimed: bigint; + withdrawRequestedAt: number; + refundNonce: bigint; +}; + +export type ChannelConfig = { + payer: `0x${string}`; + payerAuthorizer: `0x${string}`; + receiver: `0x${string}`; + receiverAuthorizer: `0x${string}`; + token: `0x${string}`; + withdrawDelay: number; + salt: `0x${string}`; +}; + +export type BatchSettlementErc3009Authorization = { + validAfter: string; + validBefore: string; + salt: `0x${string}`; + signature: `0x${string}`; +}; + +export type BatchSettlementPermit2Authorization = { + from: `0x${string}`; + permitted: { + token: `0x${string}`; + amount: string; + }; + spender: `0x${string}`; + nonce: string; + deadline: string; + witness: { + channelId: `0x${string}`; + }; + signature: `0x${string}`; +}; + +export type BatchSettlementAssetTransferMethod = "eip3009" | "permit2"; + +export type BatchSettlementDepositAuthorization = + | { + erc3009Authorization: BatchSettlementErc3009Authorization; + permit2Authorization?: never; + } + | { + erc3009Authorization?: never; + permit2Authorization: BatchSettlementPermit2Authorization; + }; + +export type BatchSettlementDepositPayload = { + type: "deposit"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; + deposit: { + amount: string; + authorization: BatchSettlementDepositAuthorization; + }; +}; + +export type BatchSettlementVoucherPayload = { + type: "voucher"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; +}; + +export type BatchSettlementRefundPayload = { + type: "refund"; + channelConfig: ChannelConfig; + voucher: BatchSettlementVoucherFields; + amount?: string; +}; + +export type BatchSettlementVoucherFields = { + channelId: `0x${string}`; + maxClaimableAmount: string; + signature: `0x${string}`; +}; + +export type BatchSettlementVoucherClaim = { + voucher: { + channel: ChannelConfig; + maxClaimableAmount: string; + }; + signature: `0x${string}`; + totalClaimed: string; +}; + +export type BatchSettlementChannelStateExtra = { + channelId: `0x${string}`; + balance: string; + totalClaimed: string; + withdrawRequestedAt: number; + refundNonce: string; + chargedCumulativeAmount?: string; +}; + +export type BatchSettlementVoucherStateExtra = { + signedMaxClaimable?: string; + signature?: `0x${string}`; +}; + +export type BatchSettlementPaymentRequirementsExtra = { + receiverAuthorizer: `0x${string}`; + withdrawDelay: number; + name: string; + version: string; + assetTransferMethod?: BatchSettlementAssetTransferMethod; + channelState?: BatchSettlementChannelStateExtra; + voucherState?: BatchSettlementVoucherStateExtra; +}; + +export type FileChannelStorageOptions = { + /** Root directory; channels are stored under `{directory}/{client|server}/{channelId}.json`. */ + directory: string; +}; + +export type BatchSettlementPaymentResponseExtra = { + chargedAmount?: string; + channelState?: BatchSettlementChannelStateExtra; + voucherState?: BatchSettlementVoucherStateExtra; +}; + +export type BatchSettlementClaimPayload = { + type: "claim"; + claims: BatchSettlementVoucherClaim[]; + claimAuthorizerSignature?: `0x${string}`; +}; + +export type BatchSettlementSettlePayload = { + type: "settle"; + receiver: `0x${string}`; + token: `0x${string}`; +}; + +export type BatchSettlementEnrichedRefundPayload = BatchSettlementRefundPayload & { + amount: string; + refundNonce: string; + claims: BatchSettlementVoucherClaim[]; + refundAuthorizerSignature?: `0x${string}`; + claimAuthorizerSignature?: `0x${string}`; +}; + +export type BatchSettlementPayload = + | BatchSettlementDepositPayload + | BatchSettlementVoucherPayload + | BatchSettlementRefundPayload; + +export type BatchSettlementFacilitatorSettlePayload = + | BatchSettlementDepositPayload + | BatchSettlementClaimPayload + | BatchSettlementSettlePayload + | BatchSettlementEnrichedRefundPayload; + +/** + * Returns true when the value is a non-null object (a usable record). + * + * @param payload - Value of unknown shape. + * @returns True if `payload` is an object that can be indexed by string keys. + */ +function isObject(payload: unknown): payload is Record { + return typeof payload === "object" && payload !== null; +} + +/** + * Type guard for internal voucher field shape (channel, amount, signature). + * + * @param payload - Unknown value to check. + * @returns True if `payload` is an object with `channelId`, `maxClaimableAmount`, and `signature`. + */ +function isVoucherFields(payload: unknown): payload is BatchSettlementVoucherFields { + return ( + isObject(payload) && + "channelId" in payload && + "maxClaimableAmount" in payload && + "signature" in payload + ); +} + +/** + * Type guard for {@link BatchSettlementDepositPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a deposit payload (carries `deposit` and `voucher`). + */ +export function isBatchSettlementDepositPayload( + payload: unknown, +): payload is BatchSettlementDepositPayload { + return ( + isObject(payload) && + payload.type === "deposit" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) && + isObject(payload.deposit) && + typeof payload.deposit.amount === "string" && + isObject(payload.deposit.authorization) + ); +} + +/** + * Type guard for {@link BatchSettlementVoucherPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a voucher payload with channel and signature fields. + */ +export function isBatchSettlementVoucherPayload( + payload: unknown, +): payload is BatchSettlementVoucherPayload { + return ( + isObject(payload) && + payload.type === "voucher" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) + ); +} + +/** + * Type guard for {@link BatchSettlementRefundPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a refund payload with channel config and voucher fields. + */ +export function isBatchSettlementRefundPayload( + payload: unknown, +): payload is BatchSettlementRefundPayload { + return ( + isObject(payload) && + payload.type === "refund" && + "channelConfig" in payload && + isVoucherFields(payload.voucher) + ); +} + +/** + * Type guard for {@link BatchSettlementClaimPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `claimWithSignature` payload. + */ +export function isBatchSettlementClaimPayload( + payload: unknown, +): payload is BatchSettlementClaimPayload { + return isObject(payload) && payload.type === "claim" && "claims" in payload; +} + +/** + * Type guard for {@link BatchSettlementSettlePayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `settle` payload. + */ +export function isBatchSettlementSettlePayload( + payload: unknown, +): payload is BatchSettlementSettlePayload { + return ( + isObject(payload) && payload.type === "settle" && "receiver" in payload && "token" in payload + ); +} + +/** + * Type guard for {@link BatchSettlementEnrichedRefundPayload}. + * + * @param payload - Unknown payload to check. + * @returns True if `payload` is a settle-action `refundWithSignature` payload. + */ +export function isBatchSettlementEnrichedRefundPayload( + payload: unknown, +): payload is BatchSettlementEnrichedRefundPayload { + return ( + isBatchSettlementRefundPayload(payload) && + "amount" in payload && + "refundNonce" in payload && + "claims" in payload + ); +} diff --git a/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts b/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts new file mode 100644 index 0000000..4604205 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/utils.ts @@ -0,0 +1,47 @@ +import { getAddress, hashTypedData } from "viem"; +import { BATCH_SETTLEMENT_ADDRESS, BATCH_SETTLEMENT_DOMAIN, channelConfigTypes } from "./constants"; +import type { ChannelConfig } from "./types"; +import { getEvmChainId } from "../utils"; + +/** + * Computes the chain-bound channel id from a {@link ChannelConfig} struct. + * + * @param config - The immutable channel configuration. + * @param networkOrChainId - CAIP-2 network identifier or numeric EVM chain id. + * @returns The `bytes32` channel id as a hex string. + */ +export function computeChannelId( + config: ChannelConfig, + networkOrChainId: string | number, +): `0x${string}` { + const chainId = + typeof networkOrChainId === "number" ? networkOrChainId : getEvmChainId(networkOrChainId); + return hashTypedData({ + domain: getBatchSettlementEip712Domain(chainId), + types: channelConfigTypes, + primaryType: "ChannelConfig", + message: { + payer: config.payer, + payerAuthorizer: config.payerAuthorizer, + receiver: config.receiver, + receiverAuthorizer: config.receiverAuthorizer, + token: config.token, + withdrawDelay: config.withdrawDelay, + salt: config.salt, + }, + }); +} + +/** + * Returns the full EIP-712 domain for the batch-settlement contract on the given chain. + * + * @param chainId - Numeric EVM chain id. + * @returns EIP-712 domain with `name`, `version`, `chainId`, and checksummed `verifyingContract`. + */ +export function getBatchSettlementEip712Domain(chainId: number) { + return { + ...BATCH_SETTLEMENT_DOMAIN, + chainId, + verifyingContract: getAddress(BATCH_SETTLEMENT_ADDRESS), + } as const; +} diff --git a/typescript/packages/mechanisms/evm/src/index.ts b/typescript/packages/mechanisms/evm/src/index.ts index 38277af..922647e 100644 --- a/typescript/packages/mechanisms/evm/src/index.ts +++ b/typescript/packages/mechanisms/evm/src/index.ts @@ -36,6 +36,46 @@ export { UptoEvmScheme } from "./upto"; export type { UptoPermit2Payload, UptoPermit2Witness, UptoPermit2Authorization } from "./types"; export { isUptoPermit2Payload } from "./types"; +// Batch-settlement shared types +export type { + AuthorizerSigner, + ChannelConfig, + ChannelState, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementVoucherFields, + BatchSettlementErc3009Authorization, + BatchSettlementPermit2Authorization, + BatchSettlementDepositAuthorization, + BatchSettlementAssetTransferMethod, + BatchSettlementClaimPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementVoucherClaim, + BatchSettlementPayload, + BatchSettlementSettlePayload, + BatchSettlementFacilitatorSettlePayload, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, +} from "./types"; +export { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementRefundPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementEnrichedRefundPayload, +} from "./types"; +export { + BATCH_SETTLEMENT_ADDRESS, + BATCH_SETTLEMENT_SCHEME, + ERC3009_DEPOSIT_COLLECTOR_ADDRESS, + BATCH_SETTLEMENT_DOMAIN, + voucherTypes, + refundTypes, + claimBatchTypes, +} from "./batch-settlement/constants"; + // Constants export { PERMIT2_ADDRESS, diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions.ts b/typescript/packages/mechanisms/evm/src/shared/extensions.ts index 11baee6..e3910b3 100644 --- a/typescript/packages/mechanisms/evm/src/shared/extensions.ts +++ b/typescript/packages/mechanisms/evm/src/shared/extensions.ts @@ -8,6 +8,13 @@ import { signEip2612Permit } from "../exact/client/eip2612"; import { signErc20ApprovalTransaction } from "../exact/client/erc20approval"; import { resolveExtensionRpcCapabilities, type ExactEvmSchemeOptions } from "./rpc"; +export { + BUILDER_CODE_KEY, + resolveDataSuffix, + appendDataSuffix, +} from "./extensions/index"; +export type { DataSuffixContext, BuilderCodeFacilitatorExtension } from "./extensions/index"; + /** * Attempts to sign an EIP-2612 permit for gasless Permit2 approval. * diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts new file mode 100644 index 0000000..30f7920 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/builderCode.ts @@ -0,0 +1,90 @@ +import type { + FacilitatorContext, + FacilitatorExtension, + PaymentPayload, + PaymentRequirements, +} from "@x402/core/types"; +import type { Hex } from "viem"; + +export const BUILDER_CODE_KEY = "builder-code" as const; + +export interface DataSuffixContext { + paymentPayload: PaymentPayload; + paymentRequirements: PaymentRequirements; +} + +export interface BuilderCodeFacilitatorExtension extends FacilitatorExtension { + key: typeof BUILDER_CODE_KEY; + buildDataSuffix?(ctx: DataSuffixContext): Hex | undefined | Promise; +} + +type DataSuffixResolver = ( + context: FacilitatorContext, + ctx: DataSuffixContext, +) => Promise; + +const BUILDER_CODE_RESOLVER: DataSuffixResolver = async (context, ctx) => { + const ext = context.getExtension(BUILDER_CODE_KEY); + if (!ext?.buildDataSuffix) { + return undefined; + } + + return ext.buildDataSuffix(ctx); +}; + +const DATA_SUFFIX_RESOLVERS: DataSuffixResolver[] = [BUILDER_CODE_RESOLVER]; + +/** + * Resolves and concatenates data suffixes from registered extensions. + * + * @param context - Facilitator context with registered extensions + * @param ctx - Data suffix context passed to extension resolvers + * @returns Hex-encoded suffix to append to settlement calldata, or undefined if none + */ +export async function resolveDataSuffix( + context: FacilitatorContext | undefined, + ctx: DataSuffixContext, +): Promise { + if (!context) { + return undefined; + } + + const parts: Hex[] = []; + for (const resolver of DATA_SUFFIX_RESOLVERS) { + const suffix = await resolver(context, ctx); + if (suffix && suffix !== "0x" && suffix.length > 2) { + parts.push(suffix); + } + } + + if (parts.length === 0) { + return undefined; + } + + if (parts.length === 1) { + return parts[0]; + } + + return parts.reduce((acc, part, index) => { + if (index === 0) { + return part; + } + const stripped = part.startsWith("0x") ? part.slice(2) : part; + return `${acc}${stripped}` as Hex; + }); +} + +/** + * Appends a hex data suffix to encoded contract calldata. + * + * @param calldata - Base encoded function calldata + * @param suffix - Optional hex suffix (with or without 0x prefix) + * @returns Calldata with suffix appended, or the original calldata when suffix is empty + */ +export function appendDataSuffix(calldata: Hex, suffix?: Hex): Hex { + if (!suffix || suffix === "0x" || suffix.length <= 2) { + return calldata; + } + const suffixHex = suffix.startsWith("0x") ? suffix.slice(2) : suffix; + return `${calldata}${suffixHex}` as Hex; +} diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts new file mode 100644 index 0000000..a96353c --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/gasSponsoring.ts @@ -0,0 +1,139 @@ +import type { + PaymentRequirements, + PaymentPayloadResult, + PaymentPayloadContext, +} from "@x402/core/types"; +import { + EIP2612_GAS_SPONSORING_KEY, + ERC20_APPROVAL_GAS_SPONSORING_KEY, +} from "../../exact/extensions"; +import { getAddress } from "viem"; +import { PERMIT2_ADDRESS, erc20AllowanceAbi } from "../../constants"; +import { getEvmChainId } from "../../utils"; +import { ClientEvmSigner } from "../../signer"; +import { signEip2612Permit } from "../../exact/client/eip2612"; +import { signErc20ApprovalTransaction } from "../../exact/client/erc20approval"; +import { resolveExtensionRpcCapabilities, type ExactEvmSchemeOptions } from "../rpc"; + +export async function trySignEip2612PermitExtension( + signer: ClientEvmSigner, + options: ExactEvmSchemeOptions | undefined, + requirements: PaymentRequirements, + result: PaymentPayloadResult, + context?: PaymentPayloadContext, + approvalAmount?: string, +): Promise | undefined> { + const capabilities = resolveExtensionRpcCapabilities(requirements.network, signer, options); + + if (!capabilities.readContract) { + return undefined; + } + + if (!context?.extensions?.[EIP2612_GAS_SPONSORING_KEY]) { + return undefined; + } + + const tokenName = requirements.extra?.name as string | undefined; + const tokenVersion = requirements.extra?.version as string | undefined; + if (!tokenName || !tokenVersion) { + return undefined; + } + + const chainId = getEvmChainId(requirements.network); + const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const requiredAllowance = approvalAmount ?? requirements.amount; + + try { + const allowance = (await capabilities.readContract({ + address: tokenAddress, + abi: erc20AllowanceAbi, + functionName: "allowance", + args: [signer.address, PERMIT2_ADDRESS], + })) as bigint; + + if (allowance >= BigInt(requiredAllowance)) { + return undefined; + } + } catch { + // Allowance check failed, proceed with signing. + } + + const permit2Auth = result.payload?.permit2Authorization as Record | undefined; + const deadline = + (permit2Auth?.deadline as string) ?? + Math.floor(Date.now() / 1000 + requirements.maxTimeoutSeconds).toString(); + + const info = await signEip2612Permit( + { + address: signer.address, + signTypedData: msg => signer.signTypedData(msg), + readContract: capabilities.readContract, + }, + tokenAddress, + tokenName, + tokenVersion, + chainId, + deadline, + requiredAllowance, + ); + + return { + [EIP2612_GAS_SPONSORING_KEY]: { info }, + }; +} + +export async function trySignErc20ApprovalExtension( + signer: ClientEvmSigner, + options: ExactEvmSchemeOptions | undefined, + requirements: PaymentRequirements, + context?: PaymentPayloadContext, + approvalAmount?: string, +): Promise | undefined> { + const capabilities = resolveExtensionRpcCapabilities(requirements.network, signer, options); + + if (!capabilities.readContract) { + return undefined; + } + + if (!context?.extensions?.[ERC20_APPROVAL_GAS_SPONSORING_KEY]) { + return undefined; + } + + if (!capabilities.signTransaction || !capabilities.getTransactionCount) { + return undefined; + } + + const chainId = getEvmChainId(requirements.network); + const tokenAddress = getAddress(requirements.asset) as `0x${string}`; + const requiredAllowance = approvalAmount ?? requirements.amount; + + try { + const allowance = (await capabilities.readContract({ + address: tokenAddress, + abi: erc20AllowanceAbi, + functionName: "allowance", + args: [signer.address, PERMIT2_ADDRESS], + })) as bigint; + + if (allowance >= BigInt(requiredAllowance)) { + return undefined; + } + } catch { + // Allowance check failed, proceed with signing. + } + + const info = await signErc20ApprovalTransaction( + { + address: signer.address, + signTransaction: capabilities.signTransaction, + getTransactionCount: capabilities.getTransactionCount, + estimateFeesPerGas: capabilities.estimateFeesPerGas, + }, + tokenAddress, + chainId, + ); + + return { + [ERC20_APPROVAL_GAS_SPONSORING_KEY]: { info }, + }; +} diff --git a/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts b/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts new file mode 100644 index 0000000..1e34751 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/extensions/index.ts @@ -0,0 +1,5 @@ +export { trySignEip2612PermitExtension, trySignErc20ApprovalExtension } from "./gasSponsoring"; + +export { BUILDER_CODE_KEY, resolveDataSuffix, appendDataSuffix } from "./builderCode"; + +export type { DataSuffixContext, BuilderCodeFacilitatorExtension } from "./builderCode"; diff --git a/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts b/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts new file mode 100644 index 0000000..3da9624 --- /dev/null +++ b/typescript/packages/mechanisms/evm/src/shared/verifySignature.ts @@ -0,0 +1,254 @@ +import { + hashTypedData, + recoverAddress, + isAddressEqual, + getAddress, + parseErc6492Signature, +} from "viem"; +import type { TypedDataDomain } from "viem"; +import type { FacilitatorEvmSigner } from "../signer"; + +/** + * Parsed ERC-6492 classification for a payer address. + * + * `isCounterfactual` is true when the payment comes from an undeployed smart wallet + * (ERC-6492 wrapper present, no bytecode at the payer address yet). In this case + * pre-verification of the signature is deferred to on-chain simulation or settle. + */ +export type Erc6492Classification = { + isCounterfactual: boolean; + isDeployedAtPayer: boolean; + hasDeploymentInfo: boolean; + innerSignature: `0x${string}`; + eip6492Deployment?: { factoryAddress: `0x${string}`; factoryCalldata: `0x${string}` }; +}; + +const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** + * Classify an ERC-6492 payer in one RPC round-trip: parse the sig wrapper, fetch code, + * and determine counterfactual vs deployed state. + * + * @param signer - Facilitator signer used to call `eth_getCode` on the payer address. + * @param signature - The full signature, which may be an ERC-6492 wrapper. + * @param payerAddress - The address whose bytecode is fetched to detect deployment state. + * @returns Classification result including counterfactual flag, deployment state, and inner signature. + */ +export async function classifyErc6492Payer( + signer: FacilitatorEvmSigner, + signature: `0x${string}`, + payerAddress: `0x${string}`, +): Promise { + const erc6492Data = parseErc6492Signature(signature); + const hasDeploymentInfo = !!( + erc6492Data.address && + erc6492Data.data && + !isAddressEqual(erc6492Data.address, ZERO_ADDRESS) + ); + const innerSignature = hasDeploymentInfo ? erc6492Data.signature : signature; + const eip6492Deployment = hasDeploymentInfo + ? { factoryAddress: erc6492Data.address!, factoryCalldata: erc6492Data.data! } + : undefined; + + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address: payerAddress }); + } catch { + code = undefined; + } + const isDeployedAtPayer = !!(code && code !== "0x"); + const isCounterfactual = hasDeploymentInfo && !isDeployedAtPayer; + + return { + isCounterfactual, + isDeployedAtPayer, + hasDeploymentInfo, + innerSignature, + eip6492Deployment, + }; +} + +/** + * Strict signature verification primitive that mirrors on-chain SignatureChecker + * semantics exactly: + * + * if signer.code.length == 0: + * ecrecover(digest, sig) == signer + * else: + * IERC1271(signer).isValidSignature(digest, sig) == 0x1626ba7e + * + * This matches: + * - Permit2 (libraries/SignatureVerification.sol) + * - USDC v2.2 (Circle's util/SignatureChecker.sol) + * - x402BatchSettlement (uses OpenZeppelin SignatureChecker) + * + * It deliberately does NOT fall back to ECDSA when EIP-1271 returns failure. + * That fallback (which viem's `publicClient.verifyTypedData` performs) makes + * pre-verify accept signatures that on-chain rejects — most visibly for + * ERC-7702 delegated EOAs whose delegate's `isValidSignature` does not accept + * raw owner ECDSA. With this primitive, pre-verify outcome == on-chain outcome. + * + * Plain EOAs (no code) take the ecrecover path and behave identically to before. + * 7702-delegated EOAs take the EIP-1271 path because they have code; the delegate + * decides — same as on-chain. + */ +const ERC1271_MAGIC_VALUE = "0x1626ba7e" as const; + +const ERC1271_ABI = [ + { + name: "isValidSignature", + type: "function", + stateMutability: "view", + inputs: [ + { name: "hash", type: "bytes32" }, + { name: "signature", type: "bytes" }, + ], + outputs: [{ name: "", type: "bytes4" }], + }, +] as const; + +/** + * Verify a typed-data signature using strict on-chain SignatureChecker semantics. + * + * @param signer - Facilitator signer used for `eth_getCode` and `isValidSignature` calls. + * @param params - Typed-data verification parameters. + * @param params.address - The address that is expected to have signed the data. + * @param params.domain - EIP-712 domain. + * @param params.types - EIP-712 type definitions. + * @param params.primaryType - The primary type to hash. + * @param params.message - The typed-data message. + * @param params.signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export async function verifyTypedDataSignature( + signer: FacilitatorEvmSigner, + params: { + address: `0x${string}`; + domain: TypedDataDomain; + types: Record; + primaryType: string; + message: Record; + signature: `0x${string}`; + }, +): Promise { + let digest: `0x${string}`; + try { + digest = hashTypedData({ + domain: params.domain, + types: params.types, + primaryType: params.primaryType, + message: params.message, + }); + } catch { + // Malformed typed data (e.g. non-checksummed address that viem rejects). + // Treat as an invalid signature rather than propagating the error. + return false; + } + return verifyHashSignature(signer, params.address, digest, params.signature); +} + +/** + * Lower-level variant of {@link verifyTypedDataSignature} for callers that already have the digest. + * + * @param signer - Facilitator signer used for `eth_getCode` and `isValidSignature` calls. + * @param address - The address that is expected to have produced the signature. + * @param digest - The EIP-191 / EIP-712 message hash to verify against. + * @param signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export async function verifyHashSignature( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + // getCode must be guarded: a transient RPC error should return false (invalid sig + // semantics) rather than propagating as an unhandled rejection. All callers rely on + // this function returning a boolean, never throwing. + let code: `0x${string}` | undefined; + try { + code = await signer.getCode({ address }); + } catch { + return false; + } + return verifyHashSignatureWithCode(signer, address, code, digest, signature); +} + +/** + * Like {@link verifyHashSignature} but accepts pre-fetched bytecode to avoid a + * redundant `eth_getCode` RPC when the caller already has it (e.g. after the + * ERC-6492 counterfactual check in {@link classifyErc6492Payer}). + * + * Pass `undefined` or `"0x"` for `code` to take the EOA (ecrecover) path. + * + * @param signer - Facilitator signer used for `isValidSignature` calls on deployed contracts. + * @param address - The address that is expected to have produced the signature. + * @param code - Pre-fetched bytecode at `address`; `undefined` or `"0x"` takes the ECDSA path. + * @param digest - The message hash to verify against. + * @param signature - The signature to verify. + * @returns `true` if the signature is valid, `false` otherwise. + */ +export function verifyHashSignatureWithCode( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + code: `0x${string}` | undefined, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + if (!code || code === "0x") { + return verifyECDSA(address, digest, signature); + } + return verifyERC1271(signer, address, digest, signature); +} + +/** + * ecrecover path — used when the address has no code. + * + * @param address - The address expected to be recovered from the signature. + * @param digest - The message hash to recover from. + * @param signature - The compact 65-byte ECDSA signature. + * @returns `true` if ecrecover produces `address`, `false` otherwise. + */ +export async function verifyECDSA( + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + const sigHex = signature.startsWith("0x") ? signature.slice(2) : signature; + if (sigHex.length !== 130) return false; + try { + const recovered = await recoverAddress({ hash: digest, signature }); + return isAddressEqual(getAddress(recovered), getAddress(address)); + } catch { + return false; + } +} + +/** + * Strict EIP-1271 path — returns false on revert or non-magic return, never falls back to ECDSA. + * + * @param signer - Facilitator signer used to call `isValidSignature` on the contract. + * @param address - The contract address whose `isValidSignature` is called. + * @param digest - The hash passed as the first argument to `isValidSignature`. + * @param signature - The signature bytes passed as the second argument. + * @returns `true` if `isValidSignature` returns the ERC-1271 magic value, `false` otherwise. + */ +export async function verifyERC1271( + signer: FacilitatorEvmSigner, + address: `0x${string}`, + digest: `0x${string}`, + signature: `0x${string}`, +): Promise { + try { + const result = (await signer.readContract({ + address, + abi: ERC1271_ABI, + functionName: "isValidSignature", + args: [digest, signature], + })) as `0x${string}` | undefined; + if (typeof result !== "string") return false; + return result.toLowerCase().startsWith(ERC1271_MAGIC_VALUE); + } catch { + return false; + } +} diff --git a/typescript/packages/mechanisms/evm/src/signer.ts b/typescript/packages/mechanisms/evm/src/signer.ts index 66ec3de..f46e83a 100644 --- a/typescript/packages/mechanisms/evm/src/signer.ts +++ b/typescript/packages/mechanisms/evm/src/signer.ts @@ -1,3 +1,5 @@ +import type { Log } from "viem"; + /** * ClientEvmSigner - Used by x402 clients to sign payment authorizations. * @@ -88,11 +90,14 @@ export type FacilitatorEvmSigner = { abi: readonly unknown[]; functionName: string; args: readonly unknown[]; - /** Optional gas limit. When provided, skips eth_estimateGas simulation. */ gas?: bigint; + dataSuffix?: `0x${string}`; }): Promise<`0x${string}`>; sendTransaction(args: { to: `0x${string}`; data: `0x${string}` }): Promise<`0x${string}`>; - waitForTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ status: string }>; + waitForTransactionReceipt(args: { hash: `0x${string}` }): Promise<{ + status: string; + logs?: readonly Log[]; + }>; getCode(args: { address: `0x${string}` }): Promise<`0x${string}` | undefined>; }; diff --git a/typescript/packages/mechanisms/evm/src/types.ts b/typescript/packages/mechanisms/evm/src/types.ts index 0463327..f4db534 100644 --- a/typescript/packages/mechanisms/evm/src/types.ts +++ b/typescript/packages/mechanisms/evm/src/types.ts @@ -109,6 +109,37 @@ export type UptoPermit2Payload = { }; }; +// Batch-settlement EVM scheme payload types +export type { + AuthorizerSigner, + ChannelConfig, + ChannelState, + BatchSettlementDepositPayload, + BatchSettlementVoucherPayload, + BatchSettlementRefundPayload, + BatchSettlementVoucherFields, + BatchSettlementErc3009Authorization, + BatchSettlementPermit2Authorization, + BatchSettlementDepositAuthorization, + BatchSettlementAssetTransferMethod, + BatchSettlementClaimPayload, + BatchSettlementEnrichedRefundPayload, + BatchSettlementVoucherClaim, + BatchSettlementPayload, + BatchSettlementSettlePayload, + BatchSettlementFacilitatorSettlePayload, + BatchSettlementPaymentRequirementsExtra, + BatchSettlementPaymentResponseExtra, +} from "./batch-settlement/types"; +export { + isBatchSettlementDepositPayload, + isBatchSettlementVoucherPayload, + isBatchSettlementRefundPayload, + isBatchSettlementClaimPayload, + isBatchSettlementSettlePayload, + isBatchSettlementEnrichedRefundPayload, +} from "./batch-settlement/types"; + /** * Type guard to check if a payload is an upto Permit2 payload. * Validates structural presence of all required fields: signature, permit2Authorization diff --git a/typescript/packages/mechanisms/evm/tsup.config.ts b/typescript/packages/mechanisms/evm/tsup.config.ts index db132c6..66c2066 100644 --- a/typescript/packages/mechanisms/evm/tsup.config.ts +++ b/typescript/packages/mechanisms/evm/tsup.config.ts @@ -12,6 +12,7 @@ const baseConfig = { "upto/client/index": "src/upto/client/index.ts", "upto/server/index": "src/upto/server/index.ts", "upto/facilitator/index": "src/upto/facilitator/index.ts", + "batch-settlement/facilitator/index": "src/batch-settlement/facilitator/index.ts", }, dts: { resolve: true,