diff --git a/README.md b/README.md index d93502c..577bf41 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The default server path is intentionally short: - server-broadcast Permit2 credentials - client-broadcast transaction-hash credentials - fee sponsorship - - split payments + - split payments via ordered sequential Permit2 transfers - replay protection - instructive RFC 9457-compatible errors - Session flows: @@ -287,8 +287,8 @@ just release-prep ## Draft Caveats - Charge direct settlement still signs the challenge recipient as the spender because PR 205 does not yet expose a separate spender field. -- Split charge payments use a batch Permit2 extension when more than one transfer leg is needed. -- Session receipts stay `mppx`-compatible in v1. Richer session acceptance state is returned alongside the resource instead of inside the serialized `Payment-Receipt` header. +- Split charge payments use ordered `authorizations[]` and settle sequentially. They are not atomic, and `credentialMode: "hash"` is rejected for split requests. +- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header. Generic `mppx` receipt parsing still drops that field because upstream `mppx` does not include it in the shared receipt schema. - Session gas sponsorship is out of scope in v1. The payer wallet pays gas for `open` and `topUp`, while the server settlement wallet pays gas for `settle` and `close`. ## License diff --git a/demo/shared/descriptors.ts b/demo/shared/descriptors.ts index 3b5f457..9af514f 100644 --- a/demo/shared/descriptors.ts +++ b/demo/shared/descriptors.ts @@ -41,5 +41,5 @@ export const demoDescriptions = { export const demoDraftCaveats = [ "Direct settlement signs the recipient as the spender because the draft spec does not yet expose a dedicated spender field.", - "Split payments use the SDK batch Permit2 extension while PR 205 remains open.", + 'Split payments use ordered Permit2 "authorizations[]" and settle sequentially. Hash mode stays disabled for split charges because PR 205 still lacks a multi-hash split flow.', ] as const; diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 54853f4..3ae7d7c 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -427,7 +427,9 @@ export const mppx = Mppx.create({ Notes: - `charge` defaults to Permit2 credential mode. That is the simplest path when the server settles. -- If the payer must broadcast the charge directly, set `credentialMode: "hash"`. Omit `submissionMode` to use `realtime`, or set it explicitly when you need `sync` or `sendAndWait`. +- If the payer must broadcast the charge directly, set `credentialMode: "hash"` only for unsplit charge requests. Omit `submissionMode` to use `realtime`, or set it explicitly when you need `sync` or `sendAndWait`. +- Split charge requests use ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first. +- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, while generic `mppx` receipt parsing still drops that field. - `session` needs a `deposit` on the client unless the server challenge already includes `suggestedDeposit`. ## Express Server Recipe @@ -576,7 +578,7 @@ The repository includes a working Durable Object store adapter at ## Verification Checklist 1. Confirm the server returns `402` with a payment challenge before the route is paid. -2. Confirm the route returns your resource plus a `Payment-Receipt` header after payment succeeds. +2. Confirm the route returns your resource plus a `Payment-Receipt` header after payment succeeds. If you need `challengeId`, read the raw header JSON instead of relying on generic `mppx` receipt parsing. 3. Confirm the client wallet is connected to the same `chainId` as the challenge. 4. Confirm the payer wallet has MegaETH gas plus the payment token balance. 5. For `charge`, confirm the payer approved Permit2 once. @@ -594,6 +596,8 @@ The repository includes a working Durable Object store adapter at - Set `submissionMode` to `sync` or `sendAndWait` before retrying the broadcast flow. - Charge hash mode used with server-sponsored gas: - Switch back to `credentialMode: "permit2"` before retrying because the server requested fee sponsorship. +- Charge hash mode used with splits: + - Switch back to `credentialMode: "permit2"` before retrying because split charges now settle through multiple sequential Permit2 transfers. - Permit2 allowance missing: - Approve Permit2 for the payment token before retrying the first charge. - Session escrow missing: diff --git a/docs/demo.md b/docs/demo.md index c2af3e5..8275138 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -76,9 +76,11 @@ The UI runs at `http://localhost:5173`. - `MEGAETH_SUBMISSION_MODE` feeds directly into the SDK `submissionMode` and must be `sync`, `realtime`, or `sendAndWait`. - `mode=permit2` uses the server-broadcast charge runtime. -- `mode=hash` uses the client-broadcast verification runtime. -- Split payments are driven per request through `methodDetails.splits`. +- `mode=hash` uses the client-broadcast verification runtime for unsplit charge requests. +- Split payments are driven per request through `methodDetails.splits` and settle sequentially, primary transfer first. +- The demo keeps `mode=permit2` for split routes because `credentialMode: "hash"` is rejected for split charges. - The browser charge UI checks the connected wallet's Permit2 allowance and can prompt for a one-time infinite approval when the current token allowance is missing or finite. +- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, but generic `mppx` receipt parsing still drops that field. ### Session diff --git a/docs/getting-started.md b/docs/getting-started.md index 68687da..84b17d0 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -41,6 +41,7 @@ When the settlement wallet is also the payee, opt in visibly with `recipient: se - `currency`: mainnet USDm - `permit2Address`: canonical Permit2 +- `methodDetails.chainId`: `4326` unless `methodDetails.testnet` is `true`, which forces `6343` ## Flow Selection @@ -180,7 +181,7 @@ const sessionMppx = Mppx.create({ | Mode | When to use it | Broadcasts the transaction | | --- | --- | --- | | `permit2` | Server should sponsor gas or own the final settlement path | Server | -| `hash` | Payer should broadcast directly and the server only verifies | Client | +| `hash` | Payer should broadcast directly and the server only verifies unsplit payments | Client | ### Submission Mode @@ -190,6 +191,13 @@ const sessionMppx = Mppx.create({ | `sync` | Advanced only | Requires `eth_sendRawTransactionSync` support | | `sendAndWait` | Conservative fallback | Uses the standard send path plus receipt polling | +Charge-specific notes: + +- Split charges are encoded as ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first. +- Split charge settlement is non-atomic. A later split can fail after the primary transfer succeeds. +- `credentialMode: "hash"` is rejected for split requests because PR 205 does not yet define a multi-hash split receipt model. +- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, but generic `mppx` receipt parsing still drops that field. + ## Charge Process Flows ### Permit2 Credential Mode diff --git a/docs/methods/charge.md b/docs/methods/charge.md index e4cd1ad..cfc5d92 100644 --- a/docs/methods/charge.md +++ b/docs/methods/charge.md @@ -6,7 +6,7 @@ This is the best fit when you want: - one Permit2-backed payment per request - a simple server-broadcast flow with fee sponsorship -- a client-broadcast fallback that returns a transaction hash instead of a server-submitted settlement +- a client-broadcast fallback that returns a transaction hash instead of a server-submitted settlement for unsplit payments For the end-to-end walkthrough, start with [../getting-started.md](../getting-started.md). @@ -62,8 +62,11 @@ Use explicit overrides when you are: - Client signs Permit2 typed data. - Server verifies the challenge, signature, token, amount, splits, and source DID. -- Server broadcasts the Permit2 transaction from the settlement wallet. +- Server broadcasts one Permit2 transaction per transfer leg from the settlement wallet. +- Split authorizations are ordered `authorizations[]`: the primary transfer first, then each split in request order. +- Split settlement is sequential and non-atomic. The primary transfer can succeed even if a later split fails. - This is the fee-sponsored path. +- Because PR 205 does not yet define a separate Permit2 spender field, the server-broadcast path still requires `recipient` to equal the settlement wallet address. ### Transaction Hash Credential (Client Broadcast) @@ -83,6 +86,7 @@ type ChargeRequest = { externalId?: string methodDetails: { chainId?: number + testnet?: boolean feePayer?: boolean permit2Address?: `0x${string}` splits?: Array<{ @@ -94,16 +98,24 @@ type ChargeRequest = { } ``` -`methodDetails.chainId` is the only public network selector. The old `testnet` -flag is gone. Provide `chainId` either through explicit create-level defaults -or on each request. +Use one of these network selectors: + +- `methodDetails.testnet: true`: forces MegaETH testnet `6343` +- otherwise `methodDetails.chainId ?? 4326` + +The SDK keeps `testnet` for PR 205 compatibility, but `chainId` remains the +preferred explicit selector when you already know the exact network. ## Client Credential Mode The client charge factory accepts an optional `credentialMode` parameter: - `permit2`: return a signed Permit2 credential for server-side verification and broadcast -- `hash`: broadcast the Permit2 transaction from the payer wallet and return a transaction-hash credential +- `hash`: broadcast the Permit2 transaction from the payer wallet and return a transaction-hash credential for unsplit payments + +`credentialMode: "hash"` is rejected when `methodDetails.splits` is present +because PR 205 still defines only one hash field while split settlement now uses +multiple non-atomic Permit2 calls. ## Submission Mode @@ -119,10 +131,13 @@ defaults to `realtime`. Set it explicitly when you need `sync` or ## Receipt Behavior -`mppx` currently serializes a generic payment receipt header. The SDK keeps the MegaETH request and credential wire format aligned to the draft spec, while the receipt remains compatible with the shared `mppx` receipt serializer. +`mppx` still exposes a generic receipt schema. The SDK keeps the MegaETH +request and credential wire format aligned to the draft spec, and its default +MegaETH HTTP transport adds `challengeId` to the raw `Payment-Receipt` header. ```ts type ChargeReceipt = { + challengeId: string method: "megaeth" reference: string status: "success" @@ -131,7 +146,10 @@ type ChargeReceipt = { } ``` -`challengeId` remains available in server verification context and problem details, but it is not part of the serialized `Payment-Receipt` header. +The SDK's default MegaETH HTTP transport writes `challengeId` into the raw +`Payment-Receipt` header JSON. Generic `mppx` receipt parsing still drops that +field because the upstream receipt schema does not include it, so read the raw +header value directly when your client needs `challengeId`. ## Client Progress Lifecycle @@ -154,3 +172,8 @@ All server failures are intentionally instructive. The caller should learn what - switch back to `credentialMode: "permit2"` when the server sponsors gas The verification layer maps those failures onto `mppx.Errors.*` problem-details classes so callers can inspect both the human-readable detail and the RFC 9457 `type` URI. + +For `charge`, the important result classes are: + +- `invalid-challenge` for expired, consumed, or otherwise invalid challenges +- `verification-failed` for signature mismatches, split mismatches, hash-mode misuse, and on-chain settlement failures diff --git a/docs/methods/session.md b/docs/methods/session.md index 5377ba8..1c60d50 100644 --- a/docs/methods/session.md +++ b/docs/methods/session.md @@ -67,7 +67,10 @@ const result = await mppx.megaeth.session({ - the payer or delegated signer signs cumulative EIP-712 vouchers - the server accepts vouchers, settles periodically, and closes cooperatively -The serialized `Payment-Receipt` header stays `mppx`-compatible in v1. Richer channel state is returned separately by the SDK and demo. +The default MegaETH HTTP transport writes `challengeId` into the raw +`Payment-Receipt` header. Generic `mppx` receipt parsing still drops that field +because the shared upstream receipt schema does not include it. Richer channel +state still returns separately through the SDK and demo. ## Request Shape diff --git a/typescript/packages/mpp/README.md b/typescript/packages/mpp/README.md index 35f9126..6da799c 100644 --- a/typescript/packages/mpp/README.md +++ b/typescript/packages/mpp/README.md @@ -56,6 +56,12 @@ const mppx = Mppx.create({ }); ``` +## Charge Notes + +- Split `charge` requests use ordered Permit2 `authorizations[]` and settle sequentially, primary transfer first. +- `credentialMode: "hash"` is supported only for unsplit charge requests. +- The default MegaETH HTTP transport writes `challengeId` into the raw `Payment-Receipt` header, while generic `mppx` receipt parsing still drops that field. + ## Docs - Coding agents: [github.com/ifavo/mega-mpp-sdk/blob/main/docs/agent-integration.md](https://github.com/ifavo/mega-mpp-sdk/blob/main/docs/agent-integration.md) diff --git a/typescript/packages/mpp/src/Methods.ts b/typescript/packages/mpp/src/Methods.ts index 64c2cb6..746ea60 100644 --- a/typescript/packages/mpp/src/Methods.ts +++ b/typescript/packages/mpp/src/Methods.ts @@ -1,6 +1,7 @@ import { Method, z } from "mppx"; import { baseUnitIntegerString } from "./utils/baseUnit.js"; +import { MAX_SPLITS } from "./constants.js"; const tokenPermissionSchema = z.object({ token: z.address(), @@ -15,7 +16,16 @@ const transferDetailSchema = z.object({ export const splitSchema = z.object({ recipient: z.address(), amount: baseUnitIntegerString("split amount"), - memo: z.optional(z.string()), + memo: z.optional( + z + .string() + .check( + z.maxLength( + 256, + "Use a split memo no longer than 256 characters before retrying the payment.", + ), + ), + ), }); const permitSingleSchema = z.object({ @@ -24,18 +34,14 @@ const permitSingleSchema = z.object({ deadline: baseUnitIntegerString("deadline"), }); -const permitBatchSchema = z.object({ - permitted: z.array(tokenPermissionSchema), - nonce: baseUnitIntegerString("nonce"), - deadline: baseUnitIntegerString("deadline"), -}); - const witnessSingleSchema = z.object({ transferDetails: transferDetailSchema, }); -const witnessBatchSchema = z.object({ - transferDetails: z.array(transferDetailSchema), +const permitAuthorizationSchema = z.object({ + permit: permitSingleSchema, + witness: witnessSingleSchema, + signature: z.signature(), }); export const sessionOpenPayloadSchema = z.object({ @@ -81,9 +87,14 @@ export const charge = Method.from({ payload: z.discriminatedUnion("type", [ z.object({ type: z.literal("permit2"), - permit: z.union([permitSingleSchema, permitBatchSchema]), - witness: z.union([witnessSingleSchema, witnessBatchSchema]), - signature: z.signature(), + authorizations: z + .array(permitAuthorizationSchema) + .check( + z.minLength( + 1, + "Use at least one signed Permit2 authorization before retrying the payment.", + ), + ), }), z.object({ type: z.literal("hash"), @@ -99,9 +110,25 @@ export const charge = Method.from({ recipient: z.address(), methodDetails: z.object({ chainId: z.optional(z.number()), + testnet: z.optional(z.boolean()), feePayer: z.optional(z.boolean()), permit2Address: z.optional(z.address()), - splits: z.optional(z.array(splitSchema)), + splits: z.optional( + z + .array(splitSchema) + .check( + z.minLength( + 1, + "Use at least one split recipient before retrying the payment.", + ), + ) + .check( + z.maxLength( + MAX_SPLITS, + `Use at most ${MAX_SPLITS} split recipients in one payment request.`, + ), + ), + ), }), }), }, @@ -150,9 +177,10 @@ export type ChargeRequest = z.output; export type ChargeSplit = z.output; export type TransferDetail = z.output; export type PermitSinglePayload = z.output; -export type PermitBatchPayload = z.output; export type TransferSingleWitness = z.output; -export type TransferBatchWitness = z.output; +export type ChargePermitAuthorization = z.output< + typeof permitAuthorizationSchema +>; export type ChargePermit2Payload = Extract< z.output, { type: "permit2" } @@ -165,6 +193,7 @@ export type ChargeCredentialPayload = z.output< typeof charge.schema.credential.payload >; export type ChargeReceipt = { + challengeId: string; method: "megaeth"; reference: string; status: "success"; diff --git a/typescript/packages/mpp/src/__tests__/fixtures/mockContracts.ts b/typescript/packages/mpp/src/__tests__/fixtures/mockContracts.ts index 62d2b05..12b1dba 100644 --- a/typescript/packages/mpp/src/__tests__/fixtures/mockContracts.ts +++ b/typescript/packages/mpp/src/__tests__/fixtures/mockContracts.ts @@ -113,6 +113,12 @@ contract MockPermit2 { } mapping(address => mapping(uint256 => bool)) public usedNonces; + address public failRecipientAfterFirstSuccess; + uint256 public successfulTransfers; + + function setFailRecipientAfterFirstSuccess(address recipient) external { + failRecipientAfterFirstSuccess = recipient; + } function permitWitnessTransferFrom( PermitTransferFrom calldata permit, @@ -131,14 +137,22 @@ contract MockPermit2 { transferDetails.requestedAmount == permit.permitted.amount, "Use the exact requested amount before retrying the payment." ); + require( + !( + successfulTransfers > 0 && + transferDetails.to == failRecipientAfterFirstSuccess + ), + "Retry after the split transfer settles successfully." + ); require( IERC20Like(permit.permitted.token).transferFrom( owner, transferDetails.to, transferDetails.requestedAmount ), - "Retry after the payment token transfer succeeds." - ); + "Retry after the payment token transfer succeeds." + ); + successfulTransfers += 1; } function permitWitnessTransferFrom( diff --git a/typescript/packages/mpp/src/__tests__/integration.test.ts b/typescript/packages/mpp/src/__tests__/integration.test.ts index 786b200..0e2eb28 100644 --- a/typescript/packages/mpp/src/__tests__/integration.test.ts +++ b/typescript/packages/mpp/src/__tests__/integration.test.ts @@ -14,6 +14,7 @@ import { mnemonicToAccount } from "viem/accounts"; import { deployContract, getTransaction, + getTransactionCount, readContract, waitForTransactionReceipt, writeContract, @@ -290,7 +291,7 @@ describe("megaeth charge integration", () => { expect(recipientBalance).toBe(1000n); }); - it("settles split payments with the draft batch Permit2 extension", async () => { + it("settles split payments with sequential Permit2 transfers and returns the primary receipt hash", async () => { const context = requireTestContext(); const clientMethod = createIntegrationClientMethod(context); const serverMethod = createIntegrationServerMethod(context, { @@ -327,9 +328,12 @@ describe("megaeth charge integration", () => { credential, request: challenge.request, }); - const settlementTransaction = await getTransaction(context.publicClient, { + const primaryTransaction = await getTransaction(context.publicClient, { hash: receipt.reference as `0x${string}`, }); + const settlementNonce = await getTransactionCount(context.publicClient, { + address: context.wallets.recipient.account.address, + }); const [recipientBalance, splitBalance] = await Promise.all([ readContract(context.publicClient, { @@ -346,20 +350,41 @@ describe("megaeth charge integration", () => { }), ]); - expect(settlementTransaction.input.slice(0, 10)).toBe("0xfe8ec1a7"); + expect(primaryTransaction.input.slice(0, 10)).toBe("0x137c29fe"); + expect( + (credential.payload as SharedMethods.ChargePermit2Payload).authorizations, + ).toHaveLength(2); + expect( + BigInt( + (credential.payload as SharedMethods.ChargePermit2Payload) + .authorizations[1]!.permit.nonce, + ), + ).toBeGreaterThan( + BigInt( + (credential.payload as SharedMethods.ChargePermit2Payload) + .authorizations[0]!.permit.nonce, + ), + ); + expect(settlementNonce).toBe(2); expect(recipientBalance).toBe(900n); expect(splitBalance).toBe(100n); }); - it("verifies a transaction-hash split payment after the payer broadcasts the batch Permit2 transaction", async () => { + it("returns the primary receipt and records diagnostics when a later split settlement fails", async () => { const context = requireTestContext(); - const clientMethod = createIntegrationClientMethod(context, { - credentialMode: "hash", + await waitForTransactionReceipt(context.publicClient, { + hash: await writeContract(context.wallets.deployer, { + abi: context.contracts.mockPermit2.abi, + account: context.wallets.deployer.account, + address: context.permit2Address, + args: [context.splitAddress], + chain: megaethTestnet, + functionName: "setFailRecipientAfterFirstSuccess", + }), }); + + const clientMethod = createIntegrationClientMethod(context); const serverMethod = createIntegrationServerMethod(context, { - account: undefined, - publicClient: context.publicClient, - rpcUrls: undefined, splits: [ { amount: "100", @@ -367,7 +392,6 @@ describe("megaeth charge integration", () => { recipient: context.splitAddress, }, ], - walletClient: undefined, }); const challenge = await issueChallenge( @@ -390,25 +414,22 @@ describe("megaeth charge integration", () => { const credential = deserializeChargeCredential( await clientMethod.createCredential({ challenge }), ); - const transactionHash = ( - credential.payload as SharedMethods.ChargeHashPayload - ).hash as `0x${string}`; - const transaction = await getTransaction(context.publicClient, { - hash: transactionHash, - }); - - expect( - getAddress( - transaction.to ?? "0x0000000000000000000000000000000000000000", - ), - ).toBe(getAddress(context.permit2Address)); - expect(transaction.input.slice(0, 10)).toBe("0xfe8ec1a7"); - const receipt = await serverMethod.verify({ credential, request: challenge.request, }); + const diagnosticsValue = await context.store.get( + `megaeth:charge:split-failure:${challenge.id}`, + ); + const diagnostics = JSON.parse( + typeof diagnosticsValue === "string" ? diagnosticsValue : "[]", + ) as Array<{ + reason: string; + recipient: Address; + requestedAmount: string; + transferIndex: number; + }>; const [recipientBalance, splitBalance] = await Promise.all([ readContract(context.publicClient, { abi: context.contracts.mockErc20.abi, @@ -424,9 +445,59 @@ describe("megaeth charge integration", () => { }), ]); - expect(receipt.reference).toBe(transactionHash); + expect(receipt.reference).toMatch(/^0x[a-fA-F0-9]{64}$/); expect(recipientBalance).toBe(900n); - expect(splitBalance).toBe(100n); + expect(splitBalance).toBe(0n); + expect(diagnostics).toEqual([ + expect.objectContaining({ + recipient: context.splitAddress, + requestedAmount: "100", + transferIndex: 1, + }), + ]); + }); + + it("rejects split hash credentials because PR 205 leaves multi-hash settlement undefined", async () => { + const context = requireTestContext(); + const clientMethod = createIntegrationClientMethod(context, { + credentialMode: "hash", + }); + const serverMethod = createIntegrationServerMethod(context, { + account: undefined, + publicClient: context.publicClient, + rpcUrls: undefined, + splits: [ + { + amount: "100", + memo: "platform fee", + recipient: context.splitAddress, + }, + ], + walletClient: undefined, + }); + + const challenge = await issueChallenge( + serverMethod, + context.tokenAddress, + context.wallets.recipient.account.address, + { + chainId: megaethTestnet.id, + permit2Address: context.permit2Address, + splits: [ + { + amount: "100", + memo: "platform fee", + recipient: context.splitAddress, + }, + ], + }, + ); + + await expect( + clientMethod.createCredential({ challenge }), + ).rejects.toThrowError( + /does not define a split transaction-hash credential flow/i, + ); }); it("rejects a mutated payload that changes the requested amount", async () => { @@ -447,18 +518,26 @@ describe("megaeth charge integration", () => { const credential = deserializeChargeCredential( await clientMethod.createCredential({ challenge }), ); - const mutated = Credential.from({ - ...credential, - payload: { - ...(credential.payload as SharedMethods.ChargePermit2Payload), - permit: { - ...(credential.payload as SharedMethods.ChargePermit2Payload).permit, - permitted: { - amount: "999", - token: context.tokenAddress, + const mutatedPayload: SharedMethods.ChargePermit2Payload = { + ...(credential.payload as SharedMethods.ChargePermit2Payload), + authorizations: [ + { + ...(credential.payload as SharedMethods.ChargePermit2Payload) + .authorizations[0]!, + permit: { + ...(credential.payload as SharedMethods.ChargePermit2Payload) + .authorizations[0]!.permit, + permitted: { + amount: "999", + token: context.tokenAddress, + }, }, }, - }, + ], + }; + const mutated = Credential.from({ + ...credential, + payload: mutatedPayload, }) as ChargeCredential; await expect( diff --git a/typescript/packages/mpp/src/__tests__/permit2.test.ts b/typescript/packages/mpp/src/__tests__/permit2.test.ts index 0b19210..4d994fc 100644 --- a/typescript/packages/mpp/src/__tests__/permit2.test.ts +++ b/typescript/packages/mpp/src/__tests__/permit2.test.ts @@ -1,13 +1,9 @@ -import { Credential, Receipt } from "mppx"; -import type { Address, Hex } from "viem"; +import { Credential } from "mppx"; +import type { Address } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { describe, expect, it } from "vitest"; -import type { - ChargePermit2Payload, - ChargeReceipt, - ChargeRequest, -} from "../Methods.js"; +import type { ChargePermit2Payload, ChargeRequest } from "../Methods.js"; import { assertPermitPayloadMatchesRequest, buildTypedData, @@ -43,43 +39,55 @@ function createRequest(overrides?: Partial): ChargeRequest { }; } +async function signPermitPayload( + request: ChargeRequest, + spender: Address, +): Promise { + const unsignedPayload = createPermitPayload({ + deadline: 1_900_000_000n, + nonce: 7n, + request, + }); + + return { + ...unsignedPayload, + authorizations: await Promise.all( + unsignedPayload.authorizations.map(async (authorization) => ({ + ...authorization, + signature: await payer.signTypedData( + buildTypedData({ + authorization, + chainId: 6343, + permit2Address: "0x3333333333333333333333333333333333333333", + spender, + }), + ), + })), + ), + }; +} + describe("permit2 utilities", () => { it("creates a single-transfer permit payload that round-trips through owner recovery", async () => { const request = createRequest(); - const unsignedPayload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 7n, + const payload = await signPermitPayload( request, - }); - - const typedData = buildTypedData({ - chainId: 6343, - payload: { - ...unsignedPayload, - signature: "0x" as Hex, - }, - permit2Address: "0x3333333333333333333333333333333333333333", - spender: request.recipient as Address, - }); - - const signature = await payer.signTypedData(typedData); - const payload: ChargePermit2Payload = { - ...unsignedPayload, - signature, - }; + request.recipient as Address, + ); const recovered = await recoverPermitOwner({ + authorization: payload.authorizations[0]!, chainId: 6343, - payload, permit2Address: "0x3333333333333333333333333333333333333333", spender: request.recipient as Address, }); expect(recovered).toBe(payer.address); + expect(payload.authorizations).toHaveLength(1); expect(splitSummary()).toBe("no splits"); }); - it("creates a batch transfer plan when splits are present", () => { + it("creates ordered single-transfer authorizations when splits are present", () => { const request = createRequest({ methodDetails: { chainId: 6343, @@ -105,39 +113,45 @@ describe("permit2 utilities", () => { request, }); - expect(plan.isBatch).toBe(true); expect(plan.primaryAmount).toBe(850n); expect(plan.splitTotal).toBe(150n); - expect(Array.isArray(payload.permit.permitted)).toBe(true); + expect(payload.authorizations).toHaveLength(3); + expect( + payload.authorizations.map((authorization) => authorization.permit.nonce), + ).toEqual(["8", "9", "10"]); + expect( + payload.authorizations.map( + (authorization) => authorization.witness.transferDetails.to, + ), + ).toEqual([ + request.recipient, + "0x4444444444444444444444444444444444444444", + "0x5555555555555555555555555555555555555555", + ]); expect(splitSummary(request.methodDetails.splits)).toBe("2 splits"); }); - it("encodes single-transfer calldata with the canonical Permit2 selector", () => { + it("encodes single-transfer calldata with the canonical Permit2 selector", async () => { const request = createRequest(); - const unsignedPayload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 7n, + const payload = await signPermitPayload( request, - }); - const payload: ChargePermit2Payload = { - ...unsignedPayload, - signature: "0x1234", - }; + request.recipient as Address, + ); const calldata = encodePermit2Calldata({ + authorization: payload.authorizations[0]!, owner: payer.address, - payload, }); const decoded = decodePermit2Transaction(calldata); expect(calldata.slice(0, 10)).toBe("0x137c29fe"); expect(decoded).toEqual({ + authorization: payload.authorizations[0], owner: payer.address, - payload, }); }); - it("encodes batch-transfer calldata with the canonical Permit2 selector", () => { + it("uses the same single-transfer selector for split authorizations", async () => { const request = createRequest({ methodDetails: { chainId: 6343, @@ -150,27 +164,22 @@ describe("permit2 utilities", () => { ], }, }); - const unsignedPayload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 8n, + const payload = await signPermitPayload( request, - }); - const payload: ChargePermit2Payload = { - ...unsignedPayload, - signature: "0x1234", - }; + request.recipient as Address, + ); - const calldata = encodePermit2Calldata({ + const primaryCalldata = encodePermit2Calldata({ + authorization: payload.authorizations[0]!, owner: payer.address, - payload, }); - const decoded = decodePermit2Transaction(calldata); - - expect(calldata.slice(0, 10)).toBe("0xfe8ec1a7"); - expect(decoded).toEqual({ + const splitCalldata = encodePermit2Calldata({ + authorization: payload.authorizations[1]!, owner: payer.address, - payload, }); + + expect(primaryCalldata.slice(0, 10)).toBe("0x137c29fe"); + expect(splitCalldata.slice(0, 10)).toBe("0x137c29fe"); }); it("rejects non-Permit2 calldata with a stable payload error", () => { @@ -182,7 +191,7 @@ describe("permit2 utilities", () => { ); }); - it("rejects decoded Permit2 arguments that do not match either supported transfer shape", () => { + it("rejects decoded Permit2 arguments that do not match the supported single-transfer shape", () => { expect(() => parseDecodedTransferArguments([ { @@ -226,39 +235,12 @@ describe("permit2 utilities", () => { }); it("builds the canonical Permit2 witness type string stub", () => { - const singlePayload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 9n, - request: createRequest(), - }); - const batchPayload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 10n, - request: createRequest({ - methodDetails: { - chainId: 6343, - permit2Address: "0x3333333333333333333333333333333333333333", - splits: [ - { - amount: "100", - recipient: "0x4444444444444444444444444444444444444444", - }, - ], - }, - }), - }); - - expect( - getWitnessTypeString({ ...singlePayload, signature: "0x1234" }), - ).toBe( + expect(getWitnessTypeString()).toBe( "ChargeWitness witness)ChargeWitness(TransferDetails transferDetails)TokenPermissions(address token,uint256 amount)TransferDetails(address to,uint256 requestedAmount)", ); - expect(getWitnessTypeString({ ...batchPayload, signature: "0x1234" })).toBe( - "ChargeBatchWitness witness)ChargeBatchWitness(TransferDetails[] transferDetails)TokenPermissions(address token,uint256 amount)TransferDetails(address to,uint256 requestedAmount)", - ); }); - it("rejects a mutated payload that changes the requested transfer ordering", () => { + it("rejects a mutated payload that changes the requested transfer ordering", async () => { const request = createRequest({ methodDetails: { chainId: 6343, @@ -271,30 +253,27 @@ describe("permit2 utilities", () => { ], }, }); - - const payload = createPermitPayload({ - deadline: 1_900_000_000n, - nonce: 9n, + const payload = await signPermitPayload( request, - }); + request.recipient as Address, + ); expect(() => assertPermitPayloadMatchesRequest( { ...payload, - signature: "0x1234", - witness: { - transferDetails: [ - { - requestedAmount: "900", - to: "0x4444444444444444444444444444444444444444", - }, - { - requestedAmount: "100", - to: request.recipient, + authorizations: [ + payload.authorizations[0]!, + { + ...payload.authorizations[1]!, + witness: { + transferDetails: { + requestedAmount: "100", + to: request.recipient, + }, }, - ], - }, + }, + ], }, request, ), @@ -334,20 +313,4 @@ describe("permit2 utilities", () => { expect(parsed.challenge.id).toBe("challenge-1"); expect(parsed.source).toBe(createDidPkhSource(6343, payer.address)); }); - - it("keeps serialized receipts compatible with the shared mppx header format", () => { - const receipt: ChargeReceipt = { - externalId: "ext-1", - method: "megaeth", - reference: - "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - status: "success", - timestamp: "2026-03-25T10:15:00.000Z", - }; - - const encoded = Receipt.serialize(Receipt.from(receipt)); - const parsed = Receipt.deserialize(encoded) as ChargeReceipt; - - expect(parsed).toEqual(receipt); - }); }); diff --git a/typescript/packages/mpp/src/__tests__/replayConcurrency.test.ts b/typescript/packages/mpp/src/__tests__/replayConcurrency.test.ts index 4729068..348ad80 100644 --- a/typescript/packages/mpp/src/__tests__/replayConcurrency.test.ts +++ b/typescript/packages/mpp/src/__tests__/replayConcurrency.test.ts @@ -208,10 +208,7 @@ function createHashVerificationPublicClient(parameters: { }); const typedData = buildTypedData({ chainId: megaethTestnet.id, - payload: { - ...unsignedPayload, - signature: "0x" as Hex, - }, + authorization: unsignedPayload.authorizations[0]!, permit2Address, spender: payer.address, }); @@ -223,8 +220,8 @@ function createHashVerificationPublicClient(parameters: { const signature = await payer.signTypedData(typedData); const calldata = encodePermit2Calldata({ owner: payer.address, - payload: { - ...unsignedPayload, + authorization: { + ...unsignedPayload.authorizations[0]!, signature, }, }); diff --git a/typescript/packages/mpp/src/__tests__/server.mppx.test.ts b/typescript/packages/mpp/src/__tests__/server.mppx.test.ts index 1739bce..681325d 100644 --- a/typescript/packages/mpp/src/__tests__/server.mppx.test.ts +++ b/typescript/packages/mpp/src/__tests__/server.mppx.test.ts @@ -1,4 +1,5 @@ -import { Challenge } from "mppx"; +import { Challenge, Receipt } from "mppx"; +import { Transport } from "mppx/server"; import { getAddress, type Address } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import { describe, expect, it } from "vitest"; @@ -152,7 +153,7 @@ describe("server Mppx defaults", () => { ).rejects.toThrowError(/Provide a recipient address/i); }); - it("keeps instructive errors when charge defaults cannot resolve a chain id", async () => { + it("defaults charge requests to MegaETH mainnet when no chain selector is supplied", async () => { const method = megaeth.charge({ recipient: recipientAddress as Address, }); @@ -167,6 +168,119 @@ describe("server Mppx defaults", () => { recipient: undefined as never, }, }), - ).rejects.toThrowError(/Provide chainId/i); + ).resolves.toMatchObject({ + methodDetails: { + chainId: 4326, + }, + }); + }); + + it("prefers the testnet flag over an explicit chain id for charge requests", async () => { + const method = megaeth.charge({ + recipient: recipientAddress as Address, + }); + + await expect( + method.request?.({ + credential: undefined, + request: { + amount: "1000", + currency: tokenAddress, + methodDetails: { + chainId: 4326, + testnet: true, + }, + recipient: undefined as never, + }, + }), + ).resolves.toMatchObject({ + methodDetails: { + chainId: megaethTestnet.id, + testnet: true, + }, + }); + }); + + it("serializes the raw Payment-Receipt header with challengeId on the default HTTP transport", async () => { + const mppx = Mppx.create({ + methods: [ + megaeth.charge({ + currency: tokenAddress as Address, + recipient: recipientAddress as Address, + }), + ], + realm: "tests.megaeth.local", + secretKey: "server-test-secret", + }); + const response = mppx.transport.respondReceipt({ + challengeId: "challenge-123", + receipt: Receipt.from({ + method: "megaeth", + reference: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + status: "success", + timestamp: "2026-03-28T09:00:00.000Z", + }), + response: new Response("ok"), + }); + const encodedReceipt = response.headers.get("Payment-Receipt"); + if (!encodedReceipt) { + throw new Error( + "Attach a Payment-Receipt header before asserting challengeId serialization.", + ); + } + + const rawReceipt = JSON.parse( + Buffer.from(encodedReceipt, "base64url").toString("utf8"), + ) as { + challengeId: string; + method: string; + reference: string; + }; + + expect(rawReceipt).toMatchObject({ + challengeId: "challenge-123", + method: "megaeth", + reference: + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + }); + + it("preserves user-supplied transports instead of forcing the local HTTP override", async () => { + const transport = Transport.from({ + name: "custom-test", + getCredential() { + return null; + }, + respondChallenge() { + return { kind: "challenge" as const }; + }, + respondReceipt() { + return { kind: "receipt" as const }; + }, + }); + const mppx = Mppx.create({ + methods: [ + megaeth.charge({ + currency: tokenAddress as Address, + recipient: recipientAddress as Address, + }), + ], + realm: "tests.megaeth.local", + secretKey: "server-test-secret", + transport, + }); + + const result = await mppx.megaeth.charge({ + amount: "1000", + methodDetails: { + chainId: megaethTestnet.id, + }, + })({}); + + expect(result).toEqual({ + challenge: { kind: "challenge" }, + status: 402, + }); }); }); diff --git a/typescript/packages/mpp/src/__tests__/server.test.ts b/typescript/packages/mpp/src/__tests__/server.test.ts index 11f04be..7094be5 100644 --- a/typescript/packages/mpp/src/__tests__/server.test.ts +++ b/typescript/packages/mpp/src/__tests__/server.test.ts @@ -2,7 +2,7 @@ import { Errors } from "mppx"; import { Store } from "mppx/server"; import type { Address } from "viem"; import type * as ViemActionsModule from "viem/actions"; -import { readContract } from "viem/actions"; +import { call, readContract } from "viem/actions"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { megaethTestnet } from "../constants.js"; @@ -33,15 +33,18 @@ vi.mock("viem/actions", async () => { return { ...actual, + call: vi.fn(), readContract: vi.fn(), }; }); +const mockedCall = vi.mocked(call); const mockedReadContract = vi.mocked(readContract); const mockedSubmitTransaction = vi.mocked(submitTransaction); describe("megaeth charge server errors", () => { beforeEach(() => { + mockedCall.mockReset(); mockedReadContract.mockReset(); mockedSubmitTransaction.mockReset(); }); @@ -72,6 +75,9 @@ describe("megaeth charge server errors", () => { mockedReadContract .mockResolvedValueOnce(1_000n) .mockResolvedValueOnce(1_000n); + mockedCall.mockResolvedValue({ data: "0x" } as Awaited< + ReturnType + >); mockedSubmitTransaction.mockResolvedValueOnce( createTransactionReceipt(hash), ); @@ -90,9 +96,10 @@ describe("megaeth charge server errors", () => { submissionMode: "realtime", }), ); + expect(mockedCall).toHaveBeenCalledOnce(); }); - it("returns RFC 9457 problem details for expired challenges", async () => { + it("returns RFC 9457 invalid-challenge problem details for expired challenges", async () => { const challenge = createChallenge({ expires: new Date(Date.now() - 1_000).toISOString(), secretKey: "server-test-secret", @@ -104,12 +111,12 @@ describe("megaeth charge server errors", () => { }), ); - expect(error).toBeInstanceOf(Errors.PaymentExpiredError); + expect(error).toBeInstanceOf(Errors.InvalidChallengeError); expect(error.toProblemDetails(challenge.id)).toMatchObject({ challengeId: challenge.id, status: 402, - title: "Payment Expired", - type: "https://paymentauth.org/problems/payment-expired", + title: "Invalid Challenge", + type: "https://paymentauth.org/problems/invalid-challenge", }); }); @@ -135,7 +142,7 @@ describe("megaeth charge server errors", () => { expect(error.message).toMatch(/fresh payment challenge/i); }); - it("returns RFC 9457 problem details for hash payloads on fee-sponsored challenges", async () => { + it("returns RFC 9457 verification-failed problem details for hash payloads on fee-sponsored challenges", async () => { const challenge = createChallenge({ secretKey: "server-test-secret", request: { @@ -157,12 +164,12 @@ describe("megaeth charge server errors", () => { }), ); - expect(error).toBeInstanceOf(Errors.InvalidPayloadError); + expect(error).toBeInstanceOf(Errors.VerificationFailedError); expect(error.toProblemDetails(challenge.id)).toMatchObject({ challengeId: challenge.id, status: 402, - title: "Invalid Payload", - type: "https://paymentauth.org/problems/invalid-payload", + title: "Verification Failed", + type: "https://paymentauth.org/problems/verification-failed", }); expect(error.message).toMatch( /Permit2 credential instead of a hash credential/i, diff --git a/typescript/packages/mpp/src/client/Charge.ts b/typescript/packages/mpp/src/client/Charge.ts index 8fc8035..469b3c9 100644 --- a/typescript/packages/mpp/src/client/Charge.ts +++ b/typescript/packages/mpp/src/client/Charge.ts @@ -4,7 +4,7 @@ import type { Account, Address, Hex } from "viem"; import * as Methods from "../Methods.js"; import { resolveAccount, - resolveChainId, + resolveChargeChainId, resolvePublicClient, resolveWalletClient, type WalletClientResolver, @@ -35,11 +35,12 @@ export function charge( return Method.toClient(Methods.charge, { async createCredential({ challenge }) { - const chainId = resolveChainId(challenge.request.methodDetails); + const chainId = resolveChargeChainId(challenge.request.methodDetails); const walletClient = await resolveWalletClient(parameters, chainId); const signer = resolveAccount(walletClient, account); const permit2Address = getPermit2Address(challenge.request); const returnsTransactionHashCredential = credentialMode === "hash"; + const hasSplits = Boolean(challenge.request.methodDetails.splits?.length); if ( returnsTransactionHashCredential && @@ -49,6 +50,11 @@ export function charge( 'Use credentialMode "permit2" for this challenge because the server asked to sponsor gas. Retry after switching away from the transaction-hash credential flow.', ); } + if (returnsTransactionHashCredential && hasSplits) { + throw new Error( + 'Use credentialMode "permit2" for split payments because PR 205 does not define a split transaction-hash credential flow.', + ); + } const deadline = BigInt( challenge.expires @@ -78,28 +84,31 @@ export function charge( ? signer.address : challenge.request.recipient ) as Address; - const typedData = buildTypedData({ - chainId, - payload: { - ...unsignedPayload, - signature: "0x" as Hex, - }, - permit2Address, - spender, - }); - onProgress?.({ type: "signing" }); - const signature = await walletClient.signTypedData({ - account: signer, - domain: typedData.domain, - message: typedData.message, - primaryType: typedData.primaryType, - types: typedData.types, - }); - const payload: Methods.ChargePermit2Payload = { - ...unsignedPayload, - signature, + type: "permit2", + authorizations: await Promise.all( + unsignedPayload.authorizations.map(async (authorization) => { + const typedData = buildTypedData({ + authorization, + chainId, + permit2Address, + spender, + }); + const signature = await walletClient.signTypedData({ + account: signer, + domain: typedData.domain, + message: typedData.message, + primaryType: typedData.primaryType, + types: typedData.types, + }); + + return { + ...authorization, + signature, + }; + }), + ), }; if (credentialMode === "permit2") { @@ -120,8 +129,8 @@ export function charge( ); const publicClient = await resolvePublicClient(parameters, chainId); const calldata = encodePermit2Calldata({ + authorization: payload.authorizations[0]!, owner: signer.address, - payload, }); onProgress?.({ type: "paying" }); diff --git a/typescript/packages/mpp/src/index.ts b/typescript/packages/mpp/src/index.ts index 5035f54..458e67b 100644 --- a/typescript/packages/mpp/src/index.ts +++ b/typescript/packages/mpp/src/index.ts @@ -2,11 +2,11 @@ export { charge, megaeth, session } from "./Methods.js"; export type { ChargeCredentialPayload, ChargeHashPayload, + ChargePermitAuthorization, ChargePermit2Payload, ChargeReceipt, ChargeRequest, ChargeSplit, - PermitBatchPayload, PermitSinglePayload, SessionClosePayload, SessionCredentialPayload, @@ -15,7 +15,6 @@ export type { SessionRequest, SessionTopUpPayload, SessionVoucherPayload, - TransferBatchWitness, TransferDetail, TransferSingleWitness, } from "./Methods.js"; diff --git a/typescript/packages/mpp/src/server/Charge.ts b/typescript/packages/mpp/src/server/Charge.ts index b64fe93..3e38131 100644 --- a/typescript/packages/mpp/src/server/Charge.ts +++ b/typescript/packages/mpp/src/server/Charge.ts @@ -1,6 +1,7 @@ import { Errors, Receipt, Method } from "mppx"; import { Store } from "mppx/server"; import { + call, getTransaction, getTransactionReceipt, readContract, @@ -10,6 +11,8 @@ import { type Account, type Address, type PublicClient, + type TransactionReceipt, + type WalletClient, } from "viem"; import { ERC20_ABI } from "../abi.js"; @@ -17,7 +20,7 @@ import { DEFAULT_USDM, PERMIT2_ADDRESS } from "../constants.js"; import * as Methods from "../Methods.js"; import { resolveAccount, - resolveChainId, + resolveChargeChainId, resolvePublicClient, resolveWalletClient, type WalletClientResolver, @@ -79,6 +82,7 @@ export function charge( const chainId = resolveRequestChainId({ chainId: request.methodDetails.chainId ?? normalizedParameters.chainId, + testnet: request.methodDetails.testnet, }); const resolvedCurrency = resolveRequestAddress({ configured: configuredCurrency, @@ -98,6 +102,7 @@ export function charge( methodDetails: { ...request.methodDetails, chainId, + ...(request.methodDetails.testnet ? { testnet: true } : {}), ...(normalizedParameters.feePayer !== undefined ? { feePayer: normalizedParameters.feePayer } : {}), @@ -113,7 +118,10 @@ export function charge( async verify({ credential }) { const challenge = credential.challenge.request; const challengeId = credential.challenge.id; - const chainId = resolveCredentialChainId(challenge.methodDetails); + const chainId = resolveCredentialChainId( + challenge.methodDetails, + challengeId, + ); const publicClient = await resolvePublicClient( normalizedParameters, chainId, @@ -123,9 +131,10 @@ export function charge( credential.challenge.expires && new Date(credential.challenge.expires) < new Date() ) { - throw new Errors.PaymentExpiredError({ - expires: credential.challenge.expires, - }); + throw invalidChallenge( + challengeId, + "Request a fresh payment challenge before retrying because this challenge has expired", + ); } try { @@ -167,11 +176,8 @@ export function charge( throw error; } - if (error instanceof Permit2PayloadError) { - throw invalidPayload(error.message); - } - if ( + error instanceof Permit2PayloadError || error instanceof Permit2ValidationError || error instanceof Permit2VerificationError ) { @@ -211,10 +217,15 @@ async function verifyHashCredential(parameters: { } if (challenge.methodDetails.feePayer) { - throw invalidPayload( + throw verificationFailed( "Use a Permit2 credential instead of a hash credential for this challenge because the server asked to sponsor gas", ); } + if (challenge.methodDetails.splits?.length) { + throw verificationFailed( + 'Use credentialMode "permit2" for split payments because PR 205 does not define a split transaction-hash credential flow.', + ); + } const receipt = await getTransactionReceipt(publicClient, { hash }); if (receipt.status !== "success") { @@ -234,11 +245,17 @@ async function verifyHashCredential(parameters: { } const decoded = decodePermit2Transaction(transaction.input); - assertPermitPayloadMatchesRequest(decoded.payload, challenge); + assertPermitPayloadMatchesRequest( + { + type: "permit2", + authorizations: [decoded.authorization], + }, + challenge, + ); const owner = await recoverPermitOwner({ + authorization: decoded.authorization, chainId, - payload: decoded.payload, permit2Address: getPermit2Address(challenge), spender: transaction.from, }); @@ -303,16 +320,10 @@ async function verifyPermitCredential(parameters: { const permit2Address = getPermit2Address(challenge); const plan = assertPermitPayloadMatchesRequest(payload, challenge); - - if (BigInt(payload.permit.deadline) < BigInt(Math.floor(Date.now() / 1000))) { - throw verificationFailed( - "Use a Permit2 signature with a future deadline before retrying the payment", - ); - } - - const owner = await recoverPermitOwner({ + assertFutureDeadlines(payload); + const owner = await recoverAuthorizationOwner({ + authorizations: payload.authorizations, chainId, - payload, permit2Address, spender: challenge.recipient as Address, }); @@ -322,42 +333,252 @@ async function verifyPermitCredential(parameters: { owner, permit2Address, publicClient, - requiredAmount: plan.primaryAmount + plan.splitTotal, + requiredAmount: plan.totalAmount, token: challenge.currency as Address, }); - const calldata = encodePermit2Calldata({ + await simulateAuthorizations({ + account: settlementAccount, + authorizations: payload.authorizations, owner, - payload, + permit2Address, + publicClient, }); - const receipt = await submitTransaction({ + const primaryReceipt = await settleAuthorizations({ account: settlementAccount, + authorizations: payload.authorizations, + challengeId, chainId, - data: calldata, + owner, + permit2Address, publicClient, submissionMode: resolvedSubmissionMode, - to: permit2Address, + store, walletClient, }); - - if (receipt.status !== "success") { - throw verificationFailed( - "Retry with a Permit2 payload that simulates successfully on MegaETH before requesting the resource again", - ); - } - - await store.put(getChallengeStoreKey(challengeId), receipt.transactionHash); + await store.put( + getChallengeStoreKey(challengeId), + primaryReceipt.transactionHash, + ); return Receipt.from({ method: "megaeth", - reference: receipt.transactionHash, + reference: primaryReceipt.transactionHash, status: "success", timestamp: new Date().toISOString(), ...(challenge.externalId ? { externalId: challenge.externalId } : {}), }); } +function assertFutureDeadlines(payload: Methods.ChargePermit2Payload): void { + const now = BigInt(Math.floor(Date.now() / 1_000)); + + for (const [index, authorization] of payload.authorizations.entries()) { + const deadline = BigInt(authorization.permit.deadline); + if (deadline <= now) { + throw new Permit2VerificationError( + `Use a Permit2 payload with a future deadline for ${describeAuthorization(index)} before retrying the payment.`, + ); + } + } +} + +async function recoverAuthorizationOwner(parameters: { + authorizations: Methods.ChargePermitAuthorization[]; + chainId: number; + permit2Address: Address; + spender: Address; +}): Promise
{ + const [firstAuthorization, ...restAuthorizations] = parameters.authorizations; + if (!firstAuthorization) { + throw new Permit2VerificationError( + "Use at least one signed Permit2 authorization before retrying the payment.", + ); + } + + const owner = await recoverPermitOwner({ + authorization: firstAuthorization, + chainId: parameters.chainId, + permit2Address: parameters.permit2Address, + spender: parameters.spender, + }); + + for (const [offset, authorization] of restAuthorizations.entries()) { + const recovered = await recoverPermitOwner({ + authorization, + chainId: parameters.chainId, + permit2Address: parameters.permit2Address, + spender: parameters.spender, + }); + + if (getAddress(recovered) !== getAddress(owner)) { + throw new Permit2VerificationError( + `Sign every Permit2 authorization with the same owner wallet before retrying the payment. ${describeAuthorization(offset + 1)} was signed by a different address.`, + ); + } + } + + return owner; +} + +async function simulateAuthorizations(parameters: { + account: Account; + authorizations: Methods.ChargePermitAuthorization[]; + owner: Address; + permit2Address: Address; + publicClient: PublicClient; +}): Promise { + for (const [index, authorization] of parameters.authorizations.entries()) { + const data = encodePermit2Calldata({ + authorization, + owner: parameters.owner, + }); + + try { + await call(parameters.publicClient, { + account: parameters.account, + data, + to: parameters.permit2Address, + }); + } catch (error) { + throw new Permit2VerificationError( + `Correct ${describeAuthorization(index)} before retrying because the Permit2 settlement simulation failed: ${toReason(error)}.`, + { cause: error }, + ); + } + } +} + +async function settleAuthorizations(parameters: { + account: Account; + authorizations: Methods.ChargePermitAuthorization[]; + challengeId: string; + chainId: number; + owner: Address; + permit2Address: Address; + publicClient: PublicClient; + store: Store.Store; + submissionMode: SubmissionMode; + walletClient: WalletClient; +}): Promise { + let primaryReceipt: TransactionReceipt | undefined; + + for (const [index, authorization] of parameters.authorizations.entries()) { + const data = encodePermit2Calldata({ + authorization, + owner: parameters.owner, + }); + + try { + const receipt = await submitTransaction({ + account: parameters.account, + chainId: parameters.chainId, + data, + publicClient: parameters.publicClient, + submissionMode: parameters.submissionMode, + to: parameters.permit2Address, + walletClient: parameters.walletClient, + }); + + if (receipt.status !== "success") { + throw new Permit2VerificationError( + `Retry after ${describeAuthorization(index)} settles successfully on-chain.`, + ); + } + + if (!primaryReceipt) { + primaryReceipt = receipt; + } + } catch (error) { + if (index === 0) { + throw new Permit2VerificationError( + `Retry after the primary transfer settles successfully on-chain: ${toReason(error)}.`, + { cause: error }, + ); + } + + await recordSplitFailure({ + authorization, + challengeId: parameters.challengeId, + index, + reason: toReason(error), + store: parameters.store, + }); + } + } + + if (!primaryReceipt) { + throw new Permit2VerificationError( + "Retry after the primary transfer settles successfully on-chain.", + ); + } + + return primaryReceipt; +} + +async function recordSplitFailure(parameters: { + authorization: Methods.ChargePermitAuthorization; + challengeId: string; + index: number; + reason: string; + store: Store.Store; +}): Promise { + const key = getSplitFailureStoreKey(parameters.challengeId); + const currentValue = await parameters.store.get(key); + const failures = parseSplitFailureDiagnostics(currentValue); + const diagnostic = { + reason: normalizeReason(parameters.reason), + recipient: getAddress(parameters.authorization.witness.transferDetails.to), + requestedAmount: + parameters.authorization.witness.transferDetails.requestedAmount, + transferIndex: parameters.index, + }; + + failures.push(diagnostic); + await parameters.store.put(key, JSON.stringify(failures)); + console.error( + "[megaeth/charge] Split settlement failed after primary success", + { + ...diagnostic, + challengeId: parameters.challengeId, + }, + ); +} + +function parseSplitFailureDiagnostics(value: unknown): Array<{ + reason: string; + recipient: Address; + requestedAmount: string; + transferIndex: number; +}> { + if (typeof value !== "string") { + return []; + } + + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) + ? (parsed as Array<{ + reason: string; + recipient: Address; + requestedAmount: string; + transferIndex: number; + }>) + : []; + } catch { + return []; + } +} + +function describeAuthorization(index: number): string { + if (index === 0) { + return "the primary transfer"; + } + + return `split transfer ${index}`; +} + async function assertAllowanceAndBalance(parameters: { owner: Address; permit2Address: Address; @@ -405,7 +626,7 @@ function validateSource( const parsed = parseDidPkhSource(source); if (!parsed) { - throw invalidPayload( + throw verificationFailed( "Use a did:pkh source identifier when supplying the optional source field", ); } @@ -436,6 +657,10 @@ function getChallengeStoreKey(challengeId: string): string { return `megaeth:charge:challenge:${challengeId}`; } +function getSplitFailureStoreKey(challengeId: string): string { + return `megaeth:charge:split-failure:${challengeId}`; +} + function getVerificationLockKeys(parameters: { challengeId: string; payload: Methods.ChargeCredentialPayload; @@ -475,12 +700,6 @@ function invalidChallenge( }); } -function invalidPayload(reason: string): Errors.InvalidPayloadError { - return new Errors.InvalidPayloadError({ - reason: normalizeReason(reason), - }); -} - function verificationFailed(reason: string): Errors.VerificationFailedError { return new Errors.VerificationFailedError({ reason: normalizeReason(reason), @@ -530,21 +749,26 @@ function resolveRequestAddress(parameters: { function resolveRequestChainId(parameters: { chainId?: number | undefined; + testnet?: boolean | undefined; }): number { try { - return resolveChainId(parameters); + return resolveChargeChainId(parameters); } catch (error) { throw badRequest(toReason(error)); } } -function resolveCredentialChainId(parameters: { - chainId?: number | undefined; -}): number { +function resolveCredentialChainId( + parameters: { + chainId?: number | undefined; + testnet?: boolean | undefined; + }, + challengeId: string, +): number { try { - return resolveChainId(parameters); + return resolveChargeChainId(parameters); } catch (error) { - throw invalidPayload(toReason(error)); + throw invalidChallenge(challengeId, toReason(error)); } } diff --git a/typescript/packages/mpp/src/server/Mppx.ts b/typescript/packages/mpp/src/server/Mppx.ts index ea671fa..d890635 100644 --- a/typescript/packages/mpp/src/server/Mppx.ts +++ b/typescript/packages/mpp/src/server/Mppx.ts @@ -1,5 +1,5 @@ -import type { Method } from "mppx"; -import { Mppx as BaseMppx } from "mppx/server"; +import type { Method, Receipt as BaseReceipt } from "mppx"; +import { Mppx as BaseMppx, Transport as BaseTransport } from "mppx/server"; import type { Address } from "viem"; import type { ChargeSplit } from "../Methods.js"; @@ -18,9 +18,12 @@ type MegaethCreateDefaults = WalletClientResolver & { submissionMode?: SubmissionMode | undefined; }; -export function create( - config: create.Config, -): BaseMppx.Mppx> { +export function create< + const methods extends BaseMppx.Methods, + const transport extends BaseTransport.AnyTransport = BaseTransport.Http, +>( + config: create.Config, +): BaseMppx.Mppx, transport> { const { account, chainId, @@ -33,6 +36,7 @@ export function create( recipient, rpcUrls, submissionMode, + transport, walletClient, methods, ...baseConfig @@ -40,6 +44,7 @@ export function create( return BaseMppx.create({ ...baseConfig, + transport: (transport ?? createMegaethHttpTransport()) as transport, methods: applyMegaethCreateDefaults(methods, { account, chainId, @@ -54,14 +59,17 @@ export function create( submissionMode, walletClient, }), - } as BaseMppx.create.Config) as unknown as BaseMppx.Mppx< - ApplyMegaethMethodDefaults + } as BaseMppx.create.Config) as unknown as BaseMppx.Mppx< + ApplyMegaethMethodDefaults, + transport >; } export declare namespace create { - type Config = - BaseMppx.create.Config & MegaethCreateDefaults; + type Config< + methods extends BaseMppx.Methods = BaseMppx.Methods, + transport extends BaseTransport.AnyTransport = BaseTransport.Http, + > = BaseMppx.create.Config & MegaethCreateDefaults; } type MppxNamespace = { @@ -76,6 +84,47 @@ export const Mppx: MppxNamespace = { toNodeListener: BaseMppx.toNodeListener, }; +function createMegaethHttpTransport(): BaseTransport.Http { + const transport = BaseTransport.http(); + + return BaseTransport.from({ + ...transport, + name: "megaeth-http", + respondReceipt({ challengeId, receipt, response }) { + const headers = new Headers(response.headers); + headers.set( + "Payment-Receipt", + serializeMegaethReceipt({ + ...receipt, + challengeId, + }), + ); + + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); + }, + }); +} + +function serializeMegaethReceipt( + receipt: BaseReceipt.Receipt & { challengeId: string }, +): string { + const json = JSON.stringify(receipt); + const bytes = new TextEncoder().encode(json); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + + return btoa(binary) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); +} + type ChargeHandlerDefaults = { currency: Address; methodDetails: { diff --git a/typescript/packages/mpp/src/utils/clients.ts b/typescript/packages/mpp/src/utils/clients.ts index 370a477..6442335 100644 --- a/typescript/packages/mpp/src/utils/clients.ts +++ b/typescript/packages/mpp/src/utils/clients.ts @@ -50,6 +50,17 @@ export function resolveChainId(parameters: { return parameters.chainId; } +export function resolveChargeChainId(parameters: { + chainId?: number | undefined; + testnet?: boolean | undefined; +}): number { + if (parameters.testnet) { + return MEGAETH_TESTNET_CHAIN_ID; + } + + return parameters.chainId ?? MEGAETH_MAINNET_CHAIN_ID; +} + export function resolveChain(chainId: number): Chain { const chain = DEFAULT_CHAINS[chainId as keyof typeof DEFAULT_CHAINS]; if (!chain) { diff --git a/typescript/packages/mpp/src/utils/permit2.ts b/typescript/packages/mpp/src/utils/permit2.ts index 69d3a89..9071c9c 100644 --- a/typescript/packages/mpp/src/utils/permit2.ts +++ b/typescript/packages/mpp/src/utils/permit2.ts @@ -1,5 +1,4 @@ import { - concatHex, decodeFunctionData, encodeAbiParameters, encodeFunctionData, @@ -16,12 +15,10 @@ import { z } from "mppx"; import { PERMIT2_ABI } from "../abi.js"; import type { ChargePermit2Payload, + ChargePermitAuthorization, ChargeRequest, ChargeSplit, - PermitBatchPayload, PermitSinglePayload, - TransferBatchWitness, - TransferDetail, TransferSingleWitness, } from "../Methods.js"; import { MAX_SPLITS, PERMIT2_ADDRESS } from "../constants.js"; @@ -29,14 +26,10 @@ import { MAX_SPLITS, PERMIT2_ADDRESS } from "../constants.js"; const TOKEN_PERMISSIONS_TYPE = "TokenPermissions(address token,uint256 amount)"; const TRANSFER_DETAIL_TYPE = "TransferDetails(address to,uint256 requestedAmount)"; -const SINGLE_WITNESS_TYPE_NAME = "ChargeWitness"; -const BATCH_WITNESS_TYPE_NAME = "ChargeBatchWitness"; -const SINGLE_WITNESS_STRUCT = `${SINGLE_WITNESS_TYPE_NAME}(TransferDetails transferDetails)`; -const BATCH_WITNESS_STRUCT = `${BATCH_WITNESS_TYPE_NAME}(TransferDetails[] transferDetails)`; -const SINGLE_WITNESS_TYPE = `${SINGLE_WITNESS_STRUCT}${TRANSFER_DETAIL_TYPE}`; -const BATCH_WITNESS_TYPE = `${BATCH_WITNESS_STRUCT}${TRANSFER_DETAIL_TYPE}`; -const SINGLE_WITNESS_TYPEHASH = keccak256(stringToHex(SINGLE_WITNESS_TYPE)); -const BATCH_WITNESS_TYPEHASH = keccak256(stringToHex(BATCH_WITNESS_TYPE)); +const WITNESS_TYPE_NAME = "ChargeWitness"; +const WITNESS_STRUCT = `${WITNESS_TYPE_NAME}(TransferDetails transferDetails)`; +const WITNESS_TYPE = `${WITNESS_STRUCT}${TRANSFER_DETAIL_TYPE}`; +const WITNESS_TYPEHASH = keccak256(stringToHex(WITNESS_TYPE)); const TRANSFER_DETAIL_TYPEHASH = keccak256(stringToHex(TRANSFER_DETAIL_TYPE)); const BASE_UNIT_INTEGER_PATTERN = /^\d+$/; const HEX_BYTES_PATTERN = /^0x(?:[0-9a-fA-F]{2})*$/; @@ -49,27 +42,20 @@ type PermitTypedData = TypedDataDefinition< type PermitTypedDataTypes = PermitTypedData["types"]; type TransferPlan = { - isBatch: boolean; - permitted: Array<{ token: Address; amount: string }>; - transferDetails: TransferDetail[]; primaryAmount: bigint; splitTotal: bigint; + totalAmount: bigint; + transfers: TransferLeg[]; }; -type DecodedBatchTransfer = { - isBatch: true; - permit: { - permitted: Array<{ token: Address; amount: bigint }>; - nonce: bigint; - deadline: bigint; - }; - transferDetails: Array<{ to: Address; requestedAmount: bigint }>; - owner: Address; - signature: Hex; +type TransferLeg = { + amount: bigint; + amountString: string; + recipient: Address; + token: Address; }; -type DecodedSingleTransfer = { - isBatch: false; +type DecodedTransfer = { permit: { permitted: { token: Address; amount: bigint }; nonce: bigint; @@ -80,6 +66,13 @@ type DecodedSingleTransfer = { signature: Hex; }; +type UnsignedChargeAuthorization = Omit; + +type UnsignedChargePermit2Payload = { + type: "permit2"; + authorizations: UnsignedChargeAuthorization[]; +}; + const decodedTokenPermissionSchema = z.object({ token: z.address(), amount: z.bigint(), @@ -112,19 +105,6 @@ const decodedSingleTransferArgumentsSchema = z.tuple([ hexBytesSchema, ]); -const decodedBatchTransferArgumentsSchema = z.tuple([ - z.object({ - permitted: z.array(decodedTokenPermissionSchema), - nonce: z.bigint(), - deadline: z.bigint(), - }), - z.array(decodedTransferDetailSchema), - z.address(), - z.hash(), - z.string(), - hexBytesSchema, -]); - export class Permit2ValidationError extends Error { constructor(message: string) { super(message); @@ -154,14 +134,29 @@ export function createTransferPlan(request: ChargeRequest): TransferPlan { ); } - const splits = request.methodDetails.splits ?? []; - if (splits.length > MAX_SPLITS) { + const splits = request.methodDetails.splits; + if (splits && splits.length === 0) { + throw new Permit2ValidationError( + "Use at least one split recipient before retrying the payment.", + ); + } + + const normalizedSplits = splits ?? []; + if (normalizedSplits.length > MAX_SPLITS) { throw new Permit2ValidationError( `Use at most ${MAX_SPLITS} split recipients in one payment request.`, ); } - const splitTotal = splits.reduce( + for (const split of normalizedSplits) { + if (split.memo && split.memo.length > 256) { + throw new Permit2ValidationError( + "Use a split memo no longer than 256 characters before retrying the payment.", + ); + } + } + + const splitTotal = normalizedSplits.reduce( (sum, split) => sum + parseBaseUnitInteger(split.amount, "split amount"), 0n, ); @@ -171,38 +166,31 @@ export function createTransferPlan(request: ChargeRequest): TransferPlan { ); } + const token = getAddress(request.currency) as Address; const primaryAmount = totalAmount - splitTotal; - const permitted = [ + const transfers: TransferLeg[] = [ { - token: getAddress(request.currency) as Address, - amount: primaryAmount.toString(), + amount: primaryAmount, + amountString: primaryAmount.toString(), + recipient: getAddress(request.recipient) as Address, + token, }, - ...splits.map((split) => ({ - token: getAddress(request.currency) as Address, - amount: parseBaseUnitInteger(split.amount, "split amount").toString(), - })), - ]; - - const transferDetails = [ - { - to: getAddress(request.recipient) as Address, - requestedAmount: primaryAmount.toString(), - }, - ...splits.map((split) => ({ - to: getAddress(split.recipient) as Address, - requestedAmount: parseBaseUnitInteger( - split.amount, - "split amount", - ).toString(), - })), + ...normalizedSplits.map((split) => { + const amount = parseBaseUnitInteger(split.amount, "split amount"); + return { + amount, + amountString: amount.toString(), + recipient: getAddress(split.recipient) as Address, + token, + }; + }), ]; return { - isBatch: transferDetails.length > 1, - permitted, - transferDetails, primaryAmount, splitTotal, + totalAmount, + transfers, }; } @@ -210,88 +198,28 @@ export function createPermitPayload(parameters: { deadline: bigint; nonce: bigint; request: ChargeRequest; -}): Omit { +}): UnsignedChargePermit2Payload { const plan = createTransferPlan(parameters.request); - if (plan.isBatch) { - const permit: PermitBatchPayload = { - permitted: plan.permitted, - nonce: parameters.nonce.toString(), - deadline: parameters.deadline.toString(), - }; - const witness: TransferBatchWitness = { - transferDetails: plan.transferDetails, - }; - return { - type: "permit2", - permit, - witness, - }; - } - - const permit: PermitSinglePayload = { - permitted: plan.permitted[0]!, - nonce: parameters.nonce.toString(), - deadline: parameters.deadline.toString(), - }; - const witness: TransferSingleWitness = { - transferDetails: plan.transferDetails[0]!, - }; return { type: "permit2", - permit, - witness, - }; -} - -export function normalizePermitted( - permitted: PermitSinglePayload["permitted"] | PermitBatchPayload["permitted"], -): Array<{ token: Address; amount: string }> { - return Array.isArray(permitted) - ? permitted.map((entry) => ({ - token: getAddress(entry.token) as Address, - amount: parseBaseUnitInteger( - entry.amount, - "permitted amount", - ).toString(), - })) - : [ - { - token: getAddress(permitted.token) as Address, - amount: parseBaseUnitInteger( - permitted.amount, - "permitted amount", - ).toString(), + authorizations: plan.transfers.map((transfer, index) => ({ + permit: { + permitted: { + token: transfer.token, + amount: transfer.amountString, }, - ]; -} - -export function normalizeTransferDetails( - transferDetails: - | TransferSingleWitness["transferDetails"] - | TransferBatchWitness["transferDetails"], -): TransferDetail[] { - return Array.isArray(transferDetails) - ? transferDetails.map((entry) => ({ - to: getAddress(entry.to) as Address, - requestedAmount: parseBaseUnitInteger( - entry.requestedAmount, - "requested amount", - ).toString(), - })) - : [ - { - to: getAddress(transferDetails.to) as Address, - requestedAmount: parseBaseUnitInteger( - transferDetails.requestedAmount, - "requested amount", - ).toString(), + nonce: (parameters.nonce + BigInt(index)).toString(), + deadline: parameters.deadline.toString(), + }, + witness: { + transferDetails: { + to: transfer.recipient, + requestedAmount: transfer.amountString, }, - ]; -} - -export function isBatchPayload(payload: ChargePermit2Payload): boolean { - return Array.isArray(payload.permit.permitted); + }, + })), + }; } export function getPermit2Address(request: ChargeRequest): Address { @@ -305,119 +233,36 @@ export function assertPermitPayloadMatchesRequest( request: ChargeRequest, ): TransferPlan { const plan = createTransferPlan(request); - const permitted = normalizePermitted(payload.permit.permitted); - const transferDetails = normalizeTransferDetails( - payload.witness.transferDetails, - ); - if ( - permitted.length !== plan.permitted.length || - transferDetails.length !== plan.transferDetails.length - ) { + if (payload.authorizations.length !== plan.transfers.length) { throw new Permit2VerificationError( "Use the exact transfer count from the payment challenge before retrying. The current payload does not match the requested split layout.", ); } - for (let index = 0; index < plan.permitted.length; index += 1) { - const expectedPermit = plan.permitted[index]!; - const actualPermit = permitted[index]!; - if ( - getAddress(actualPermit.token) !== getAddress(expectedPermit.token) || - actualPermit.amount !== expectedPermit.amount - ) { - throw new Permit2VerificationError( - `Use the requested token and amount for transfer ${index + 1} before retrying. The signed Permit2 payload changed those values.`, - ); - } - } - - for (let index = 0; index < plan.transferDetails.length; index += 1) { - const expectedTransfer = plan.transferDetails[index]!; - const actualTransfer = transferDetails[index]!; - if ( - getAddress(actualTransfer.to) !== getAddress(expectedTransfer.to) || - actualTransfer.requestedAmount !== expectedTransfer.requestedAmount - ) { - throw new Permit2VerificationError( - `Use the requested recipient and amount ordering for transfer ${index + 1} before retrying. The signed Permit2 payload changed those details.`, - ); - } + for (let index = 0; index < plan.transfers.length; index += 1) { + const authorization = payload.authorizations[index]!; + const transfer = plan.transfers[index]!; + assertAuthorizationMatchesTransfer({ + authorization, + index, + transfer, + }); } return plan; } export function buildTypedData(parameters: { + authorization: ChargePermitAuthorization | UnsignedChargeAuthorization; chainId: number; - payload: ChargePermit2Payload; - permit2Address: Address; - spender: Address; -}): PermitTypedData { - const isBatch = isBatchPayload(parameters.payload); - const permitted = normalizePermitted(parameters.payload.permit.permitted); - const nonce = BigInt(parameters.payload.permit.nonce); - const deadline = BigInt(parameters.payload.permit.deadline); - - return isBatch - ? buildBatchTypedData({ - chainId: parameters.chainId, - deadline, - nonce, - payload: parameters.payload, - permit2Address: parameters.permit2Address, - permitted, - spender: parameters.spender, - }) - : buildSingleTypedData({ - chainId: parameters.chainId, - deadline, - nonce, - payload: parameters.payload, - permit2Address: parameters.permit2Address, - permitted, - spender: parameters.spender, - }); -} - -function permit2Domain(chainId: number, permit2Address: Address) { - return { - name: "Permit2", - chainId, - verifyingContract: permit2Address, - } as const; -} - -function tokenPermissionTypes(): PermitTypedDataTypes { - return { - TokenPermissions: [ - { name: "token", type: "address" }, - { name: "amount", type: "uint256" }, - ], - }; -} - -function transferDetailsTypes(): PermitTypedDataTypes { - return { - TransferDetails: [ - { name: "to", type: "address" }, - { name: "requestedAmount", type: "uint256" }, - ], - }; -} - -function buildSingleTypedData(parameters: { - chainId: number; - deadline: bigint; - nonce: bigint; - payload: ChargePermit2Payload; permit2Address: Address; - permitted: Array<{ token: Address; amount: string }>; spender: Address; }): PermitTypedData { + const permit = normalizePermit(parameters.authorization.permit); const transferDetails = normalizeTransferDetails( - parameters.payload.witness.transferDetails, - )[0]!; + parameters.authorization.witness.transferDetails, + ); return { domain: permit2Domain(parameters.chainId, parameters.permit2Address), @@ -428,22 +273,22 @@ function buildSingleTypedData(parameters: { { name: "spender", type: "address" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, - { name: "witness", type: SINGLE_WITNESS_TYPE_NAME }, + { name: "witness", type: WITNESS_TYPE_NAME }, ], ...tokenPermissionTypes(), - [SINGLE_WITNESS_TYPE_NAME]: [ + [WITNESS_TYPE_NAME]: [ { name: "transferDetails", type: "TransferDetails" }, ], ...transferDetailsTypes(), }, message: { permitted: { - token: parameters.permitted[0]!.token, - amount: BigInt(parameters.permitted[0]!.amount), + token: permit.token, + amount: BigInt(permit.amount), }, spender: parameters.spender, - nonce: parameters.nonce, - deadline: parameters.deadline, + nonce: BigInt(parameters.authorization.permit.nonce), + deadline: BigInt(parameters.authorization.permit.deadline), witness: { transferDetails: { to: transferDetails.to, @@ -454,57 +299,9 @@ function buildSingleTypedData(parameters: { }; } -function buildBatchTypedData(parameters: { - chainId: number; - deadline: bigint; - nonce: bigint; - payload: ChargePermit2Payload; - permit2Address: Address; - permitted: Array<{ token: Address; amount: string }>; - spender: Address; -}): PermitTypedData { - const transferDetails = normalizeTransferDetails( - parameters.payload.witness.transferDetails, - ); - - return { - domain: permit2Domain(parameters.chainId, parameters.permit2Address), - primaryType: "PermitBatchWitnessTransferFrom", - types: { - PermitBatchWitnessTransferFrom: [ - { name: "permitted", type: "TokenPermissions[]" }, - { name: "spender", type: "address" }, - { name: "nonce", type: "uint256" }, - { name: "deadline", type: "uint256" }, - { name: "witness", type: BATCH_WITNESS_TYPE_NAME }, - ], - ...tokenPermissionTypes(), - [BATCH_WITNESS_TYPE_NAME]: [ - { name: "transferDetails", type: "TransferDetails[]" }, - ], - ...transferDetailsTypes(), - }, - message: { - permitted: parameters.permitted.map((entry) => ({ - token: entry.token, - amount: BigInt(entry.amount), - })), - spender: parameters.spender, - nonce: parameters.nonce, - deadline: parameters.deadline, - witness: { - transferDetails: transferDetails.map((entry) => ({ - to: entry.to, - requestedAmount: BigInt(entry.requestedAmount), - })), - }, - }, - }; -} - export async function recoverPermitOwner(parameters: { + authorization: ChargePermitAuthorization; chainId: number; - payload: ChargePermit2Payload; permit2Address: Address; spender: Address; }): Promise
{ @@ -514,7 +311,7 @@ export async function recoverPermitOwner(parameters: { domain: typedData.domain, message: typedData.message, primaryType: typedData.primaryType, - signature: parameters.payload.signature as Hex, + signature: parameters.authorization.signature as Hex, types: typedData.types, } as PermitTypedData & { signature: Hex })) as Address; } catch (error) { @@ -529,72 +326,41 @@ export async function recoverPermitOwner(parameters: { } export function encodePermit2Calldata(parameters: { + authorization: ChargePermitAuthorization; owner: Address; - payload: ChargePermit2Payload; }): Hex { - const normalizedPermitted = normalizePermitted( - parameters.payload.permit.permitted, - ); - const normalizedTransferDetails = normalizeTransferDetails( - parameters.payload.witness.transferDetails, + const permit = normalizePermit(parameters.authorization.permit); + const transferDetail = normalizeTransferDetails( + parameters.authorization.witness.transferDetails, ); - const witnessHash = hashWitness(parameters.payload); - const witnessTypeString = getWitnessTypeString(parameters.payload); - if (isBatchPayload(parameters.payload)) { - return encodeFunctionData({ - abi: PERMIT2_ABI, - functionName: "permitWitnessTransferFrom", - args: [ - { - permitted: normalizedPermitted.map((entry) => ({ - token: entry.token, - amount: BigInt(entry.amount), - })), - nonce: BigInt(parameters.payload.permit.nonce), - deadline: BigInt(parameters.payload.permit.deadline), - }, - normalizedTransferDetails.map((entry) => ({ - to: entry.to, - requestedAmount: BigInt(entry.requestedAmount), - })), - parameters.owner, - witnessHash, - witnessTypeString, - parameters.payload.signature, - ], - } as Parameters[0]); - } - - const transferDetail = normalizedTransferDetails[0]!; - const permitted = normalizedPermitted[0]!; return encodeFunctionData({ abi: PERMIT2_ABI, functionName: "permitWitnessTransferFrom", args: [ { permitted: { - token: permitted.token, - amount: BigInt(permitted.amount), + token: permit.token, + amount: BigInt(permit.amount), }, - nonce: BigInt(parameters.payload.permit.nonce), - deadline: BigInt(parameters.payload.permit.deadline), + nonce: BigInt(parameters.authorization.permit.nonce), + deadline: BigInt(parameters.authorization.permit.deadline), }, { to: transferDetail.to, requestedAmount: BigInt(transferDetail.requestedAmount), }, parameters.owner, - witnessHash, - witnessTypeString, - parameters.payload.signature, + hashWitness(parameters.authorization), + getWitnessTypeString(), + parameters.authorization.signature, ], } as Parameters[0]); } export function decodePermit2Transaction(input: Hex): { + authorization: ChargePermitAuthorization; owner: Address; - payload: ChargePermit2Payload; } { let decoded: ReturnType; try { @@ -616,96 +382,51 @@ export function decodePermit2Transaction(input: Hex): { } const normalizedTransfer = parseDecodedTransferArguments(decoded.args); - if (normalizedTransfer.isBatch) { - const { owner, permit, signature, transferDetails } = normalizedTransfer; - const payload: ChargePermit2Payload = { - type: "permit2", - permit: { - permitted: permit.permitted.map((entry) => ({ - token: getAddress(entry.token) as Address, - amount: entry.amount.toString(), - })), - nonce: permit.nonce.toString(), - deadline: permit.deadline.toString(), - }, - witness: { - transferDetails: transferDetails.map((entry) => ({ - to: getAddress(entry.to) as Address, - requestedAmount: entry.requestedAmount.toString(), - })), - }, - signature, - }; - return { owner, payload }; - } - - const { owner, permit, signature, transferDetails } = normalizedTransfer; - const payload: ChargePermit2Payload = { - type: "permit2", + const authorization: ChargePermitAuthorization = { permit: { permitted: { - token: getAddress(permit.permitted.token) as Address, - amount: permit.permitted.amount.toString(), + token: getAddress(normalizedTransfer.permit.permitted.token) as Address, + amount: normalizedTransfer.permit.permitted.amount.toString(), }, - nonce: permit.nonce.toString(), - deadline: permit.deadline.toString(), + nonce: normalizedTransfer.permit.nonce.toString(), + deadline: normalizedTransfer.permit.deadline.toString(), }, witness: { transferDetails: { - to: getAddress(transferDetails.to) as Address, - requestedAmount: transferDetails.requestedAmount.toString(), + to: getAddress(normalizedTransfer.transferDetails.to) as Address, + requestedAmount: + normalizedTransfer.transferDetails.requestedAmount.toString(), }, }, - signature, + signature: normalizedTransfer.signature, }; - return { owner, payload }; -} -export function getWitnessTypeString(payload: ChargePermit2Payload): string { - return `${isBatchPayload(payload) ? BATCH_WITNESS_TYPE_NAME : SINGLE_WITNESS_TYPE_NAME} witness)${isBatchPayload(payload) ? BATCH_WITNESS_STRUCT : SINGLE_WITNESS_STRUCT}${TOKEN_PERMISSIONS_TYPE}${TRANSFER_DETAIL_TYPE}`; + return { + authorization, + owner: normalizedTransfer.owner, + }; } -export function hashWitness(payload: ChargePermit2Payload): Hex { - if (isBatchPayload(payload)) { - const transferDetails = normalizeTransferDetails( - payload.witness.transferDetails, - ); - const itemHashes = transferDetails.map(hashTransferDetail); - const arrayHash = keccak256(concatHex(itemHashes)); - return keccak256( - encodeAbiParameters( - [{ type: "bytes32" }, { type: "bytes32" }], - [BATCH_WITNESS_TYPEHASH, arrayHash], - ), - ); - } +export function getWitnessTypeString(): string { + return `${WITNESS_TYPE_NAME} witness)${WITNESS_STRUCT}${TOKEN_PERMISSIONS_TYPE}${TRANSFER_DETAIL_TYPE}`; +} +export function hashWitness( + authorization: ChargePermitAuthorization | UnsignedChargeAuthorization, +): Hex { return keccak256( encodeAbiParameters( [{ type: "bytes32" }, { type: "bytes32" }], [ - SINGLE_WITNESS_TYPEHASH, + WITNESS_TYPEHASH, hashTransferDetail( - normalizeTransferDetails(payload.witness.transferDetails)[0]!, + normalizeTransferDetails(authorization.witness.transferDetails), ), ], ), ); } -function hashTransferDetail(detail: TransferDetail): Hex { - return keccak256( - encodeAbiParameters( - [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }], - [ - TRANSFER_DETAIL_TYPEHASH, - detail.to as Address, - BigInt(detail.requestedAmount), - ], - ), - ); -} - export function splitSummary(splits?: ChargeSplit[] | undefined): string { if (!splits?.length) return "no splits"; return `${splits.length} split${splits.length === 1 ? "" : "s"}`; @@ -713,68 +434,135 @@ export function splitSummary(splits?: ChargeSplit[] | undefined): string { export function parseDecodedTransferArguments( args: readonly unknown[] | undefined, -): DecodedBatchTransfer | DecodedSingleTransfer { +): DecodedTransfer { if (!Array.isArray(args)) { throw new Permit2PayloadError( "Use a Permit2 witness transfer transaction before retrying the MegaETH payment.", ); } - return normalizeDecodedTransferArguments(args); -} - -function normalizeDecodedTransferArguments( - args: readonly unknown[], -): DecodedBatchTransfer | DecodedSingleTransfer { - const batchResult = decodedBatchTransferArgumentsSchema.safeParse(args); - if (batchResult.success) { - const [permit, transferDetails, owner, , , signature] = batchResult.data; - return { - isBatch: true, - permit: { - permitted: permit.permitted.map((entry) => ({ - amount: entry.amount, - token: getAddress(entry.token) as Address, - })), - nonce: permit.nonce, - deadline: permit.deadline, + const result = decodedSingleTransferArgumentsSchema.safeParse(args); + if (!result.success) { + throw new Permit2PayloadError( + "Use a Permit2 witness transfer transaction before retrying the MegaETH payment.", + { + cause: result.error, }, - transferDetails: transferDetails.map((entry) => ({ - requestedAmount: entry.requestedAmount, - to: getAddress(entry.to) as Address, - })), - owner: getAddress(owner) as Address, - signature: signature as Hex, - }; + ); } - const singleResult = decodedSingleTransferArgumentsSchema.safeParse(args); - if (singleResult.success) { - const [permit, transferDetails, owner, , , signature] = singleResult.data; - return { - isBatch: false, - permit: { - permitted: { - amount: permit.permitted.amount, - token: getAddress(permit.permitted.token) as Address, - }, - nonce: permit.nonce, - deadline: permit.deadline, - }, - transferDetails: { - requestedAmount: transferDetails.requestedAmount, - to: getAddress(transferDetails.to) as Address, + const [permit, transferDetails, owner, , , signature] = result.data; + return { + permit: { + permitted: { + amount: permit.permitted.amount, + token: getAddress(permit.permitted.token) as Address, }, - owner: getAddress(owner) as Address, - signature: signature as Hex, - }; + nonce: permit.nonce, + deadline: permit.deadline, + }, + transferDetails: { + requestedAmount: transferDetails.requestedAmount, + to: getAddress(transferDetails.to) as Address, + }, + owner: getAddress(owner) as Address, + signature: signature as Hex, + }; +} + +function assertAuthorizationMatchesTransfer(parameters: { + authorization: ChargePermitAuthorization; + index: number; + transfer: TransferLeg; +}): void { + const permit = normalizePermit(parameters.authorization.permit); + const transferDetails = normalizeTransferDetails( + parameters.authorization.witness.transferDetails, + ); + + if ( + getAddress(permit.token) !== getAddress(parameters.transfer.token) || + permit.amount !== parameters.transfer.amountString + ) { + throw new Permit2VerificationError( + `Use the requested token and amount for transfer ${parameters.index + 1} before retrying. The signed Permit2 payload changed those values.`, + ); } - throw new Permit2PayloadError( - "Use a Permit2 witness transfer transaction before retrying the MegaETH payment.", - { - cause: singleResult.error, - }, + if ( + getAddress(transferDetails.to) !== + getAddress(parameters.transfer.recipient) || + transferDetails.requestedAmount !== parameters.transfer.amountString + ) { + throw new Permit2VerificationError( + `Use the requested recipient and amount ordering for transfer ${parameters.index + 1} before retrying. The signed Permit2 payload changed those details.`, + ); + } +} + +function normalizePermit(permit: PermitSinglePayload): { + amount: string; + token: Address; +} { + return { + amount: parseBaseUnitInteger( + permit.permitted.amount, + "permitted amount", + ).toString(), + token: getAddress(permit.permitted.token) as Address, + }; +} + +function normalizeTransferDetails( + transferDetails: TransferSingleWitness["transferDetails"], +): TransferSingleWitness["transferDetails"] { + return { + to: getAddress(transferDetails.to) as Address, + requestedAmount: parseBaseUnitInteger( + transferDetails.requestedAmount, + "requested amount", + ).toString(), + }; +} + +function permit2Domain(chainId: number, permit2Address: Address) { + return { + name: "Permit2", + chainId, + verifyingContract: permit2Address, + } as const; +} + +function tokenPermissionTypes(): PermitTypedDataTypes { + return { + TokenPermissions: [ + { name: "token", type: "address" }, + { name: "amount", type: "uint256" }, + ], + }; +} + +function transferDetailsTypes(): PermitTypedDataTypes { + return { + TransferDetails: [ + { name: "to", type: "address" }, + { name: "requestedAmount", type: "uint256" }, + ], + }; +} + +function hashTransferDetail( + detail: TransferSingleWitness["transferDetails"], +): Hex { + return keccak256( + encodeAbiParameters( + [{ type: "bytes32" }, { type: "address" }, { type: "uint256" }], + [ + TRANSFER_DETAIL_TYPEHASH, + detail.to as Address, + BigInt(detail.requestedAmount), + ], + ), ); }