diff --git a/e2e/test.ts b/e2e/test.ts index f278753bfd..13c264058e 100644 --- a/e2e/test.ts +++ b/e2e/test.ts @@ -1268,6 +1268,7 @@ async function runTest() { const facilitatorSupportsAptos = facilitatorConfig?.protocolFamilies?.includes('aptos') ?? false; const facilitatorSupportsHedera = facilitatorConfig?.protocolFamilies?.includes('hedera') ?? false; const facilitatorSupportsStellar = facilitatorConfig?.protocolFamilies?.includes('stellar') ?? false; + const facilitatorSupportsTvm = facilitatorConfig?.protocolFamilies?.includes('tvm') ?? false; const serverConfig: ServerConfig = { port, @@ -1284,7 +1285,7 @@ async function runTest() { hederaAsset: process.env.HEDERA_ASSET, hederaAmount: process.env.HEDERA_AMOUNT, stellarPayTo: facilitatorSupportsStellar ? (serverStellarAddress || '') : '', - tvmPayTo: serverTvmAddress || '', + tvmPayTo: facilitatorSupportsTvm ? (serverTvmAddress || '') : '', networks, facilitatorUrl, mockFacilitatorUrl, diff --git a/go/.changes/unreleased/fixed-20260518-153527.yaml b/go/.changes/unreleased/fixed-20260518-153527.yaml new file mode 100644 index 0000000000..7a79128a67 --- /dev/null +++ b/go/.changes/unreleased/fixed-20260518-153527.yaml @@ -0,0 +1,3 @@ +kind: fixed +body: unwrap ERC-6492 signatures for exact/upto permit2 flows and batch-settlement +time: 2026-05-18T15:35:27.136946+02:00 diff --git a/go/mechanisms/evm/batch-settlement/encoding.go b/go/mechanisms/evm/batch-settlement/encoding.go index 79132b028b..e6a99e3c3d 100644 --- a/go/mechanisms/evm/batch-settlement/encoding.go +++ b/go/mechanisms/evm/batch-settlement/encoding.go @@ -8,6 +8,8 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" + + "github.com/x402-foundation/x402/go/mechanisms/evm" ) // erc3009DepositNonceABI is the ABI tuple (bytes32, uint256) used to derive @@ -95,7 +97,10 @@ func BuildErc3009CollectorData(validAfter, validBefore, salt, signature string) if !ok { return nil, fmt.Errorf("invalid salt: %s", salt) } - sigBytes := common.FromHex(signature) + sigBytes, err := unwrapERC6492HexSignature(signature) + if err != nil { + return nil, err + } encoded, err := erc3009CollectorDataABI.Pack(va, vb, saltBig, sigBytes) if err != nil { @@ -156,7 +161,10 @@ func BuildPermit2CollectorData(nonce, deadline, permit2Signature string, eip2612 if !ok { return nil, fmt.Errorf("invalid permit2 deadline: %s", deadline) } - sigBytes := common.FromHex(permit2Signature) + sigBytes, err := unwrapERC6492HexSignature(permit2Signature) + if err != nil { + return nil, err + } if eip2612PermitData == nil { eip2612PermitData = []byte{} } @@ -167,3 +175,12 @@ func BuildPermit2CollectorData(nonce, deadline, permit2Signature string, eip2612 } return encoded, nil } + +func unwrapERC6492HexSignature(signature string) ([]byte, error) { + sigBytes := common.FromHex(signature) + sigData, err := evm.ParseERC6492Signature(sigBytes) + if err != nil { + return nil, fmt.Errorf("invalid ERC-6492 signature: %w", err) + } + return sigData.InnerSignature, nil +} diff --git a/go/mechanisms/evm/batch-settlement/encoding_test.go b/go/mechanisms/evm/batch-settlement/encoding_test.go index ee6a0da9cc..09c1003abb 100644 --- a/go/mechanisms/evm/batch-settlement/encoding_test.go +++ b/go/mechanisms/evm/batch-settlement/encoding_test.go @@ -4,6 +4,9 @@ import ( "bytes" "strings" "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" ) func TestBuildErc3009DepositNonce_Deterministic(t *testing.T) { @@ -206,3 +209,83 @@ func TestBuildEip2612PermitData_InvalidValue(t *testing.T) { t.Fatal("expected error for non-numeric value") } } + +func TestBuildErc3009CollectorData_UnwrapsERC6492Signature(t *testing.T) { + innerSig := common.FromHex("0x" + strings.Repeat("ab", 65)) + wrapped := wrapERC6492Signature(t, innerSig) + + collectorData, err := BuildErc3009CollectorData("0", "9999999999", "0x01", "0x"+common.Bytes2Hex(wrapped)) + if err != nil { + t.Fatalf("BuildErc3009CollectorData: %v", err) + } + + signature, err := decodeErc3009CollectorSignature(collectorData) + if err != nil { + t.Fatalf("decode collector data: %v", err) + } + if string(signature) != string(innerSig) { + t.Fatalf("expected inner signature, got %x", signature) + } +} + +func TestBuildPermit2CollectorData_UnwrapsERC6492Signature(t *testing.T) { + innerSig := common.FromHex("0x" + strings.Repeat("ab", 65)) + wrapped := wrapERC6492Signature(t, innerSig) + + collectorData, err := BuildPermit2CollectorData("123", "9999999999", "0x"+common.Bytes2Hex(wrapped), nil) + if err != nil { + t.Fatalf("BuildPermit2CollectorData: %v", err) + } + + signature, err := decodePermit2CollectorSignature(collectorData) + if err != nil { + t.Fatalf("decode collector data: %v", err) + } + if string(signature) != string(innerSig) { + t.Fatalf("expected inner signature, got %x", signature) + } +} + +func wrapERC6492Signature(t *testing.T, innerSig []byte) []byte { + t.Helper() + addressTy, err := abi.NewType("address", "", nil) + if err != nil { + t.Fatalf("address type: %v", err) + } + bytesTy, err := abi.NewType("bytes", "", nil) + if err != nil { + t.Fatalf("bytes type: %v", err) + } + arguments := abi.Arguments{{Type: addressTy}, {Type: bytesTy}, {Type: bytesTy}} + packed, err := arguments.Pack( + common.HexToAddress("0xca11bde05977b3631167028862be2a173976ca11"), + []byte{0xde, 0xad, 0xbe, 0xef}, + innerSig, + ) + if err != nil { + t.Fatalf("pack: %v", err) + } + return append(packed, common.Hex2Bytes("6492649264926492649264926492649264926492649264926492649264926492")...) +} + +func decodeErc3009CollectorSignature(collectorData []byte) ([]byte, error) { + uint256Ty, _ := abi.NewType("uint256", "", nil) + bytesTy, _ := abi.NewType("bytes", "", nil) + args := abi.Arguments{{Type: uint256Ty}, {Type: uint256Ty}, {Type: uint256Ty}, {Type: bytesTy}} + unpacked, err := args.Unpack(collectorData) + if err != nil { + return nil, err + } + return unpacked[3].([]byte), nil +} + +func decodePermit2CollectorSignature(collectorData []byte) ([]byte, error) { + uint256Ty, _ := abi.NewType("uint256", "", nil) + bytesTy, _ := abi.NewType("bytes", "", nil) + args := abi.Arguments{{Type: uint256Ty}, {Type: uint256Ty}, {Type: bytesTy}, {Type: bytesTy}} + unpacked, err := args.Unpack(collectorData) + if err != nil { + return nil, err + } + return unpacked[2].([]byte), nil +} diff --git a/go/mechanisms/evm/exact/facilitator/permit2_helpers.go b/go/mechanisms/evm/exact/facilitator/permit2_helpers.go index 571503f63d..5a32f40037 100644 --- a/go/mechanisms/evm/exact/facilitator/permit2_helpers.go +++ b/go/mechanisms/evm/exact/facilitator/permit2_helpers.go @@ -51,6 +51,10 @@ func BuildPermit2SettleArgs(permit2Payload *evm.ExactPermit2Payload) (*Permit2Se if err != nil { return nil, err } + sigData, err := evm.ParseERC6492Signature(signatureBytes) + if err != nil { + return nil, err + } args := &Permit2SettleArgs{} args.Permit.Permitted.Token = common.HexToAddress(permit2Payload.Permit2Authorization.Permitted.Token) @@ -60,7 +64,7 @@ func BuildPermit2SettleArgs(permit2Payload *evm.ExactPermit2Payload) (*Permit2Se args.Owner = common.HexToAddress(permit2Payload.Permit2Authorization.From) args.Witness.To = common.HexToAddress(permit2Payload.Permit2Authorization.Witness.To) args.Witness.ValidAfter = validAfter - args.Signature = signatureBytes + args.Signature = sigData.InnerSignature return args, nil } diff --git a/go/mechanisms/evm/exact/facilitator/permit2_helpers_erc6492_test.go b/go/mechanisms/evm/exact/facilitator/permit2_helpers_erc6492_test.go new file mode 100644 index 0000000000..563d36bfa5 --- /dev/null +++ b/go/mechanisms/evm/exact/facilitator/permit2_helpers_erc6492_test.go @@ -0,0 +1,61 @@ +package facilitator + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/x402-foundation/x402/go/mechanisms/evm" +) + +func TestBuildPermit2SettleArgs_UnwrapsERC6492Signature(t *testing.T) { + innerSig := common.FromHex("0x" + strings.Repeat("ab", 65)) + wrapped := wrapERC6492Signature(t, innerSig) + + args, err := BuildPermit2SettleArgs(&evm.ExactPermit2Payload{ + Signature: "0x" + common.Bytes2Hex(wrapped), + Permit2Authorization: evm.Permit2Authorization{ + From: "0x1234567890123456789012345678901234567890", + Permitted: evm.Permit2TokenPermissions{ + Token: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Amount: "1000000", + }, + Nonce: "123", + Deadline: "9999999999", + Witness: evm.Permit2Witness{ + To: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0", + ValidAfter: "0", + }, + }, + }) + if err != nil { + t.Fatalf("BuildPermit2SettleArgs: %v", err) + } + if string(args.Signature) != string(innerSig) { + t.Fatalf("expected inner signature, got %x", args.Signature) + } +} + +func wrapERC6492Signature(t *testing.T, innerSig []byte) []byte { + t.Helper() + addressTy, err := abi.NewType("address", "", nil) + if err != nil { + t.Fatalf("address type: %v", err) + } + bytesTy, err := abi.NewType("bytes", "", nil) + if err != nil { + t.Fatalf("bytes type: %v", err) + } + arguments := abi.Arguments{{Type: addressTy}, {Type: bytesTy}, {Type: bytesTy}} + packed, err := arguments.Pack( + common.HexToAddress("0xca11bde05977b3631167028862be2a173976ca11"), + []byte{0xde, 0xad, 0xbe, 0xef}, + innerSig, + ) + if err != nil { + t.Fatalf("pack: %v", err) + } + return append(packed, common.Hex2Bytes("6492649264926492649264926492649264926492649264926492649264926492")...) +} diff --git a/go/mechanisms/evm/upto/facilitator/permit2_helpers.go b/go/mechanisms/evm/upto/facilitator/permit2_helpers.go index e8c2b5610d..af8e170111 100644 --- a/go/mechanisms/evm/upto/facilitator/permit2_helpers.go +++ b/go/mechanisms/evm/upto/facilitator/permit2_helpers.go @@ -62,6 +62,10 @@ func BuildUptoPermit2SettleArgs(permit2Payload *evm.UptoPermit2Payload, settleme if err != nil { return nil, err } + sigData, err := evm.ParseERC6492Signature(signatureBytes) + if err != nil { + return nil, err + } args := &UptoPermit2SettleArgs{} args.Permit.Permitted.Token = common.HexToAddress(permit2Payload.Permit2Authorization.Permitted.Token) @@ -73,7 +77,7 @@ func BuildUptoPermit2SettleArgs(permit2Payload *evm.UptoPermit2Payload, settleme args.Witness.To = common.HexToAddress(permit2Payload.Permit2Authorization.Witness.To) args.Witness.Facilitator = common.HexToAddress(permit2Payload.Permit2Authorization.Witness.Facilitator) args.Witness.ValidAfter = validAfter - args.Signature = signatureBytes + args.Signature = sigData.InnerSignature return args, nil } diff --git a/go/mechanisms/evm/upto/facilitator/permit2_helpers_erc6492_test.go b/go/mechanisms/evm/upto/facilitator/permit2_helpers_erc6492_test.go new file mode 100644 index 0000000000..32c2a08748 --- /dev/null +++ b/go/mechanisms/evm/upto/facilitator/permit2_helpers_erc6492_test.go @@ -0,0 +1,47 @@ +package facilitator + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" +) + +func TestBuildUptoPermit2SettleArgs_UnwrapsERC6492Signature(t *testing.T) { + innerSig := common.FromHex("0x" + strings.Repeat("ab", 65)) + wrapped := wrapERC6492Signature(t, innerSig) + + p := buildValidUptoPayload(testFacilitatorAddr) + p.Signature = "0x" + common.Bytes2Hex(wrapped) + + args, err := BuildUptoPermit2SettleArgs(p, nil) + if err != nil { + t.Fatalf("BuildUptoPermit2SettleArgs: %v", err) + } + if string(args.Signature) != string(innerSig) { + t.Fatalf("expected inner signature, got %x", args.Signature) + } +} + +func wrapERC6492Signature(t *testing.T, innerSig []byte) []byte { + t.Helper() + addressTy, err := abi.NewType("address", "", nil) + if err != nil { + t.Fatalf("address type: %v", err) + } + bytesTy, err := abi.NewType("bytes", "", nil) + if err != nil { + t.Fatalf("bytes type: %v", err) + } + arguments := abi.Arguments{{Type: addressTy}, {Type: bytesTy}, {Type: bytesTy}} + packed, err := arguments.Pack( + common.HexToAddress("0xca11bde05977b3631167028862be2a173976ca11"), + []byte{0xde, 0xad, 0xbe, 0xef}, + innerSig, + ) + if err != nil { + t.Fatalf("pack: %v", err) + } + return append(packed, common.Hex2Bytes("6492649264926492649264926492649264926492649264926492649264926492")...) +} diff --git a/python/x402/changelog.d/2352.bugfix.md b/python/x402/changelog.d/2352.bugfix.md new file mode 100644 index 0000000000..d0956d3fc7 --- /dev/null +++ b/python/x402/changelog.d/2352.bugfix.md @@ -0,0 +1 @@ +unwrap ERC-6492 signatures for permit2 flows diff --git a/python/x402/mechanisms/evm/exact/permit2_utils.py b/python/x402/mechanisms/evm/exact/permit2_utils.py index d5d10fddf3..7a470caa54 100644 --- a/python/x402/mechanisms/evm/exact/permit2_utils.py +++ b/python/x402/mechanisms/evm/exact/permit2_utils.py @@ -45,6 +45,7 @@ X402_EXACT_PERMIT2_PROXY_ADDRESS, X402_EXACT_PERMIT2_PROXY_SETTLE_WITH_PERMIT_ABI, ) +from ..erc6492 import parse_erc6492_signature # noqa: E402 from ..signer import ClientEvmSigner, FacilitatorEvmSigner # noqa: E402 from ..types import ( # noqa: E402 ExactPermit2Authorization, @@ -458,7 +459,9 @@ def _build_permit2_settle_args( Returns (permit_tuple, owner_addr, witness_tuple, sig_bytes). """ - sig_bytes = hex_to_bytes(permit2_payload.signature or "") + sig_bytes = parse_erc6492_signature( + hex_to_bytes(permit2_payload.signature or "") + ).inner_signature permit_tuple = ( ( to_checksum_address(permit2_payload.permit2_authorization.permitted.token), diff --git a/python/x402/mechanisms/evm/upto/permit2_utils.py b/python/x402/mechanisms/evm/upto/permit2_utils.py index a5727fabd6..dddc02275c 100644 --- a/python/x402/mechanisms/evm/upto/permit2_utils.py +++ b/python/x402/mechanisms/evm/upto/permit2_utils.py @@ -51,6 +51,7 @@ X402_UPTO_PERMIT2_PROXY_ADDRESS, X402_UPTO_PERMIT2_PROXY_SETTLE_WITH_PERMIT_ABI, ) +from ..erc6492 import parse_erc6492_signature # noqa: E402 # Reuse exact's allowance verification and settle error mapping from ..exact.permit2_utils import ( # noqa: E402 @@ -568,7 +569,9 @@ def _build_upto_permit2_settle_args( Returns (permit_tuple, amount, owner_addr, witness_tuple, sig_bytes). """ - sig_bytes = hex_to_bytes(permit2_payload.signature or "") + sig_bytes = parse_erc6492_signature( + hex_to_bytes(permit2_payload.signature or "") + ).inner_signature permit_tuple = ( ( to_checksum_address(permit2_payload.permit2_authorization.permitted.token), diff --git a/python/x402/tests/integrations/test_evm.py b/python/x402/tests/integrations/test_evm.py index 70603b9ff9..4e92c5cabc 100644 --- a/python/x402/tests/integrations/test_evm.py +++ b/python/x402/tests/integrations/test_evm.py @@ -28,7 +28,11 @@ ExactEvmSchemeConfig, ExactEvmServerScheme, ) -from x402.mechanisms.evm.signers import EthAccountSigner, FacilitatorWeb3Signer +from x402.mechanisms.evm.signers import ( + EthAccountSigner, + EthAccountSignerWithRPC, + FacilitatorWeb3Signer, +) from x402.mechanisms.evm.upto import ( UptoEvmClientScheme, UptoEvmFacilitatorScheme, @@ -698,10 +702,23 @@ def build_upto_payment_requirements( extra={ "assetTransferMethod": "permit2", "facilitatorAddress": facilitator_address, + # Required for EIP-2612 gas sponsoring when Permit2 allowance is absent. + "name": "USDC", + "version": "2", }, ) +# Advertise EIP-2612 gas sponsoring so the client signs a token permit when +# Permit2 allowance is insufficient (matches Go integration tests). +UPTO_EIP2612_SERVER_EXTENSIONS = { + "eip2612GasSponsoring": { + "info": {"description": "EIP-2612 gas sponsoring", "version": "1"}, + "schema": {}, + }, +} + + class UptoEvmFacilitatorClientSync: """Facilitator client wrapper for upto scheme.""" @@ -727,7 +744,9 @@ class TestEvmUptoIntegrationV2: def setup_method(self) -> None: client_account = Account.from_key(CLIENT_PRIVATE_KEY) - self.client_signer = EthAccountSigner(client_account) + # RPC reads are required so the client can detect missing Permit2 allowance + # and sign the EIP-2612 gas sponsoring extension (matches Go integration tests). + self.client_signer = EthAccountSignerWithRPC(client_account, rpc_url=RPC_URL) self.facilitator_signer = FacilitatorWeb3Signer( private_key=FACILITATOR_PRIVATE_KEY, rpc_url=RPC_URL, @@ -768,7 +787,11 @@ def test_server_should_verify_and_settle_upto_payment(self) -> None: description="LLM text generation", mime_type="application/json", ) - payment_required = self.server.create_payment_required_response(accepts, resource) + payment_required = self.server.create_payment_required_response( + accepts, + resource, + extensions=UPTO_EIP2612_SERVER_EXTENSIONS, + ) assert payment_required.x402_version == 2 @@ -782,6 +805,8 @@ def test_server_should_verify_and_settle_upto_payment(self) -> None: auth = payment_payload.payload["permit2Authorization"] assert auth["from"].lower() == self.client_address.lower() assert "facilitator" in auth["witness"] + assert payment_payload.extensions is not None + assert "eip2612GasSponsoring" in payment_payload.extensions accepted = self.server.find_matching_requirements(accepts, payment_payload) assert accepted is not None diff --git a/python/x402/tests/integrations/test_mcp_evm.py b/python/x402/tests/integrations/test_mcp_evm.py index 1556ffbe7e..c2576202e8 100644 --- a/python/x402/tests/integrations/test_mcp_evm.py +++ b/python/x402/tests/integrations/test_mcp_evm.py @@ -20,6 +20,7 @@ import time import pytest +from web3 import Web3 mcp = pytest.importorskip("mcp", reason="mcp package not available") from mcp.client.streamable_http import streamable_http_client # noqa: E402 @@ -57,6 +58,31 @@ ) +def wait_for_pending_transactions( + address: str, + rpc_url: str = RPC_URL, + timeout: float = 120.0, +) -> None: + """Wait until a wallet has no pending transactions. + + Prevents nonce collisions when integration tests share the same keys and run + back-to-back (matches Go's waitForPendingTransactions). + """ + w3 = Web3(Web3.HTTPProvider(rpc_url)) + checksum = Web3.to_checksum_address(address) + deadline = time.time() + timeout + while time.time() < deadline: + confirmed = w3.eth.get_transaction_count(checksum, "latest") + pending = w3.eth.get_transaction_count(checksum, "pending") + if pending == confirmed: + return + time.sleep(2) + raise TimeoutError( + f"Timed out waiting for pending transactions to clear for {address} " + f"(confirmed={confirmed}, pending={pending})" + ) + + class EvmFacilitatorClientSync: """Facilitator client wrapper for x402ResourceServerSync.""" @@ -275,6 +301,10 @@ def test_paid_tool_with_real_blockchain_transaction(self): WARNING: This makes REAL blockchain transactions on Base Sepolia! """ + # Prior EVM integration tests may leave facilitator txs in the mempool. + wait_for_pending_transactions(self.facilitator_signer.address) + wait_for_pending_transactions(self.client_signer.address) + # Build payment requirements config = ResourceConfig( scheme="exact", @@ -377,11 +407,17 @@ async def run_client(): result = x402_mcp.call_tool("get_weather", {"city": "New York"}) # Verify payment was made - assert result.payment_made is True - assert result.is_error is False + assert result.payment_made is True, ( + f"expected payment retry; content={result.content!r}" + ) + assert result.is_error is False, ( + f"tool call failed after payment; content={result.content!r}" + ) # Verify payment response (settlement result) - assert result.payment_response is not None + assert result.payment_response is not None, ( + f"settlement meta missing; content={result.content!r}" + ) assert result.payment_response.success is True assert result.payment_response.transaction is not None assert result.payment_response.network == TEST_NETWORK diff --git a/python/x402/tests/unit/mechanisms/evm/test_erc6492_calldata.py b/python/x402/tests/unit/mechanisms/evm/test_erc6492_calldata.py new file mode 100644 index 0000000000..3a8a3aa25c --- /dev/null +++ b/python/x402/tests/unit/mechanisms/evm/test_erc6492_calldata.py @@ -0,0 +1,93 @@ +"""Tests for ERC-6492 unwrapping in onchain calldata builders.""" + +from __future__ import annotations + +from eth_abi import encode +from eth_utils import to_checksum_address + +from x402.mechanisms.evm.constants import ERC6492_MAGIC_VALUE +from x402.mechanisms.evm.exact.permit2_utils import _build_permit2_settle_args +from x402.mechanisms.evm.types import ( + ExactPermit2Authorization, + ExactPermit2Payload, + ExactPermit2TokenPermissions, + ExactPermit2Witness, + UptoPermit2Authorization, + UptoPermit2Payload, + UptoPermit2Witness, +) +from x402.mechanisms.evm.upto.permit2_utils import _build_upto_permit2_settle_args + +PAYER = "0x1234567890123456789012345678901234567890" +TOKEN = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" +RECEIVER = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0" +FACILITATOR = "0xeF4f6ABbC1Cb87Aea6Cd86B5a4019fC6599178AC" +INNER_SIGNATURE = "0x" + "ab" * 65 + + +def _wrap_erc6492_signature(inner_signature: bytes) -> str: + factory = to_checksum_address("0xca11bde05977b3631167028862be2a173976ca11") + payload = encode( + ["address", "bytes", "bytes"], + [factory, b"\xde\xad\xbe\xef", inner_signature], + ) + wrapped = payload + ERC6492_MAGIC_VALUE + return "0x" + wrapped.hex() + + +def _make_exact_payload(signature: str) -> ExactPermit2Payload: + return ExactPermit2Payload( + signature=signature, + permit2_authorization=ExactPermit2Authorization( + from_address=PAYER, + permitted=ExactPermit2TokenPermissions(token=TOKEN, amount="1000000"), + spender="0x4020a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a000", + nonce="123", + deadline="9999999999", + witness=ExactPermit2Witness(to=RECEIVER, valid_after="0"), + ), + ) + + +def _make_upto_payload(signature: str) -> UptoPermit2Payload: + return UptoPermit2Payload( + signature=signature, + permit2_authorization=UptoPermit2Authorization( + from_address=PAYER, + permitted=ExactPermit2TokenPermissions(token=TOKEN, amount="1000000"), + spender="0x4020b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b000", + nonce="123", + deadline="9999999999", + witness=UptoPermit2Witness( + to=RECEIVER, + facilitator=FACILITATOR, + valid_after="0", + ), + ), + ) + + +def test_build_permit2_settle_args_unwraps_erc6492_signature() -> None: + inner_bytes = bytes.fromhex(INNER_SIGNATURE.removeprefix("0x")) + wrapped = _wrap_erc6492_signature(inner_bytes) + + _, _, _, sig_bytes = _build_permit2_settle_args(_make_exact_payload(wrapped)) + + assert sig_bytes == inner_bytes + + +def test_build_upto_permit2_settle_args_unwraps_erc6492_signature() -> None: + inner_bytes = bytes.fromhex(INNER_SIGNATURE.removeprefix("0x")) + wrapped = _wrap_erc6492_signature(inner_bytes) + + _, _, _, _, sig_bytes = _build_upto_permit2_settle_args(_make_upto_payload(wrapped), 1_000_000) + + assert sig_bytes == inner_bytes + + +def test_build_permit2_settle_args_leaves_non_wrapped_signature_unchanged() -> None: + inner_bytes = bytes.fromhex(INNER_SIGNATURE.removeprefix("0x")) + + _, _, _, sig_bytes = _build_permit2_settle_args(_make_exact_payload(INNER_SIGNATURE)) + + assert sig_bytes == inner_bytes diff --git a/typescript/.changeset/bumpy-cloths-eat.md b/typescript/.changeset/bumpy-cloths-eat.md new file mode 100644 index 0000000000..28163951b8 --- /dev/null +++ b/typescript/.changeset/bumpy-cloths-eat.md @@ -0,0 +1,5 @@ +--- +'@x402/evm': patch +--- + +unwrap ERC-6492 signatures for exact/upto permit2 flows and batch-settlement 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 index 9d4fd9fd05..d73a4699e4 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-eip3009.ts @@ -1,5 +1,5 @@ import { PaymentRequirements, VerifyResponse } from "@x402/core/types"; -import { getAddress } from "viem"; +import { getAddress, parseErc6492Signature } from "viem"; import { FacilitatorEvmSigner } from "../../signer"; import { BatchSettlementDepositPayload } from "../types"; import { ERC3009_DEPOSIT_COLLECTOR_ADDRESS, receiveAuthorizationTypes } from "../constants"; @@ -30,7 +30,8 @@ export function buildEip3009DepositCollectorData( throw new Error(Errors.ErrErc3009AuthorizationRequired); } - return buildErc3009CollectorData(auth.validAfter, auth.validBefore, auth.salt, auth.signature); + const { signature } = parseErc6492Signature(auth.signature); + return buildErc3009CollectorData(auth.validAfter, auth.validBefore, auth.salt, 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 index 989c6c4e94..8fe1c80349 100644 --- a/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts +++ b/typescript/packages/mechanisms/evm/src/batch-settlement/facilitator/deposit-permit2.ts @@ -4,7 +4,7 @@ import { PaymentRequirements, VerifyResponse, } from "@x402/core/types"; -import { encodeFunctionData, getAddress } from "viem"; +import { encodeFunctionData, getAddress, parseErc6492Signature } from "viem"; import { extractEip2612GasSponsoringInfo, extractErc20ApprovalGasSponsoringInfo, @@ -70,7 +70,8 @@ export function buildPermit2DepositCollectorData( throw new Error(Errors.ErrPermit2AuthorizationRequired); } - return buildPermit2CollectorData(auth.nonce, auth.deadline, auth.signature, eip2612PermitData); + const { signature } = parseErc6492Signature(auth.signature); + return buildPermit2CollectorData(auth.nonce, auth.deadline, signature, eip2612PermitData); } /** diff --git a/typescript/packages/mechanisms/evm/src/shared/permit2.ts b/typescript/packages/mechanisms/evm/src/shared/permit2.ts index ed27a72cba..588b2002a9 100644 --- a/typescript/packages/mechanisms/evm/src/shared/permit2.ts +++ b/typescript/packages/mechanisms/evm/src/shared/permit2.ts @@ -15,7 +15,7 @@ import { type Erc20ApprovalGasSponsoringFacilitatorExtension, type Erc20ApprovalGasSponsoringSigner, } from "../exact/extensions"; -import { getAddress, encodeFunctionData } from "viem"; +import { getAddress, encodeFunctionData, parseErc6492Signature } from "viem"; import { PERMIT2_ADDRESS, eip3009ABI, erc20AllowanceAbi, permit2WitnessTypes } from "../constants"; import { multicall, ContractCall } from "../multicall"; import { createPermit2Nonce, getEvmChainId } from "../utils"; @@ -524,6 +524,8 @@ export async function checkPermit2Prerequisites( * @returns Tuple of contract call arguments for the exact settle function */ export function buildExactPermit2SettleArgs(permit2Payload: Permit2PayloadBase) { + const { signature } = parseErc6492Signature(permit2Payload.signature); + return [ { permitted: { @@ -538,7 +540,7 @@ export function buildExactPermit2SettleArgs(permit2Payload: Permit2PayloadBase) to: getAddress(permit2Payload.permit2Authorization.witness.to), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, - permit2Payload.signature, + signature, ] as const; } @@ -557,6 +559,8 @@ export function buildUptoPermit2SettleArgs( settlementAmount: bigint, facilitatorAddress: `0x${string}`, ) { + const { signature } = parseErc6492Signature(permit2Payload.signature); + return [ { permitted: { @@ -573,7 +577,7 @@ export function buildUptoPermit2SettleArgs( facilitator: getAddress(facilitatorAddress), validAfter: BigInt(permit2Payload.permit2Authorization.witness.validAfter), }, - permit2Payload.signature, + signature, ] as const; } diff --git a/typescript/packages/mechanisms/evm/test/unit/erc6492-signature.test.ts b/typescript/packages/mechanisms/evm/test/unit/erc6492-signature.test.ts new file mode 100644 index 0000000000..e08da69458 --- /dev/null +++ b/typescript/packages/mechanisms/evm/test/unit/erc6492-signature.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { decodeAbiParameters, serializeErc6492Signature } from "viem"; +import { PERMIT2_DEPOSIT_COLLECTOR_ADDRESS } from "../../src/batch-settlement/constants"; +import { buildEip3009DepositCollectorData } from "../../src/batch-settlement/facilitator/deposit-eip3009"; +import { buildPermit2DepositCollectorData } from "../../src/batch-settlement/facilitator/deposit-permit2"; +import type { BatchSettlementDepositPayload } from "../../src/batch-settlement/types"; +import { x402ExactPermit2ProxyAddress, x402UptoPermit2ProxyAddress } from "../../src/constants"; +import { buildExactPermit2SettleArgs, buildUptoPermit2SettleArgs } from "../../src/shared/permit2"; +import type { ExactPermit2Payload, UptoPermit2Payload } from "../../src/types"; + +const PAYER = "0x1234567890123456789012345678901234567890" as const; +const TOKEN = "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as const; +const RECEIVER = "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0" as const; +const FACILITATOR = "0xeF4f6ABbC1Cb87Aea6Cd86B5a4019fC6599178AC" as const; +const CHANNEL_ID = "0x1111111111111111111111111111111111111111111111111111111111111111" as const; +const INNER_SIGNATURE = `0x${"ab".repeat(65)}` as `0x${string}`; +const WRAPPED_SIGNATURE = serializeErc6492Signature({ + address: "0xca11bde05977b3631167028862be2a173976ca11", + data: "0xdeadbeef", + signature: INNER_SIGNATURE, +}); + +function makeExactPermit2Payload(signature: `0x${string}`): ExactPermit2Payload { + return { + signature, + permit2Authorization: { + from: PAYER, + permitted: { token: TOKEN, amount: "1000000" }, + spender: x402ExactPermit2ProxyAddress, + nonce: "123", + deadline: "9999999999", + witness: { to: RECEIVER, validAfter: "0" }, + }, + }; +} + +function makeUptoPermit2Payload(signature: `0x${string}`): UptoPermit2Payload { + return { + signature, + permit2Authorization: { + from: PAYER, + permitted: { token: TOKEN, amount: "1000000" }, + spender: x402UptoPermit2ProxyAddress, + nonce: "123", + deadline: "9999999999", + witness: { to: RECEIVER, facilitator: FACILITATOR, validAfter: "0" }, + }, + }; +} + +function makeBatchDepositPayload( + authorization: BatchSettlementDepositPayload["deposit"]["authorization"], +): BatchSettlementDepositPayload { + return { + type: "deposit", + channelConfig: { + payer: PAYER, + payerAuthorizer: "0x0000000000000000000000000000000000000000", + receiver: RECEIVER, + receiverAuthorizer: FACILITATOR, + token: TOKEN, + withdrawDelay: 900, + salt: "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + voucher: { + channelId: CHANNEL_ID, + maxClaimableAmount: "1000000", + signature: INNER_SIGNATURE, + }, + deposit: { + amount: "1000000", + authorization, + }, + }; +} + +describe("ERC-6492 signatures in onchain calldata builders", () => { + it("unwraps exact Permit2 signatures before building settle args", () => { + const args = buildExactPermit2SettleArgs(makeExactPermit2Payload(WRAPPED_SIGNATURE)); + + expect(args[3]).toBe(INNER_SIGNATURE); + }); + + it("unwraps upto Permit2 signatures before building settle args", () => { + const args = buildUptoPermit2SettleArgs( + makeUptoPermit2Payload(WRAPPED_SIGNATURE), + 1000000n, + FACILITATOR, + ); + + expect(args[4]).toBe(INNER_SIGNATURE); + }); + + it("unwraps batch EIP-3009 signatures before encoding collector data", () => { + const collectorData = buildEip3009DepositCollectorData( + makeBatchDepositPayload({ + erc3009Authorization: { + validAfter: "0", + validBefore: "9999999999", + salt: "0x01", + signature: WRAPPED_SIGNATURE, + }, + }), + ); + + const [, , , signature] = decodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + collectorData, + ); + expect(signature).toBe(INNER_SIGNATURE); + }); + + it("unwraps batch Permit2 signatures before encoding collector data", () => { + const collectorData = buildPermit2DepositCollectorData( + makeBatchDepositPayload({ + permit2Authorization: { + from: PAYER, + permitted: { token: TOKEN, amount: "1000000" }, + spender: PERMIT2_DEPOSIT_COLLECTOR_ADDRESS, + nonce: "123", + deadline: "9999999999", + witness: { channelId: CHANNEL_ID }, + signature: WRAPPED_SIGNATURE, + }, + }), + ); + + const [, , signature] = decodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "bytes" }, { type: "bytes" }], + collectorData, + ); + expect(signature).toBe(INNER_SIGNATURE); + }); + + it("leaves non-wrapped batch EIP-3009 signatures unchanged", () => { + const collectorData = buildEip3009DepositCollectorData( + makeBatchDepositPayload({ + erc3009Authorization: { + validAfter: "0", + validBefore: "9999999999", + salt: "0x01", + signature: INNER_SIGNATURE, + }, + }), + ); + + const [, , , signature] = decodeAbiParameters( + [{ type: "uint256" }, { type: "uint256" }, { type: "uint256" }, { type: "bytes" }], + collectorData, + ); + expect(signature).toBe(INNER_SIGNATURE); + }); +});