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,