From 4b5c70520102798fc8cb89afd1d8f2a875dade88 Mon Sep 17 00:00:00 2001 From: Nav Patel Date: Wed, 29 Jul 2026 11:05:06 -0400 Subject: [PATCH] fix(evm): Base Sepolia USDC EIP-712 domain name is "USDC", not "USD Coin" --- pkg/evm/chains.go | 25 +++++++++++++++++++++---- pkg/evm/evm_test.go | 36 +++++++++++++++++++++++++++++++++--- pkg/evm/method.go | 4 ++-- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/pkg/evm/chains.go b/pkg/evm/chains.go index 84175f2..e291ef5 100644 --- a/pkg/evm/chains.go +++ b/pkg/evm/chains.go @@ -61,17 +61,34 @@ func KnownChainIDs() []uint64 { } } +// usdcDomainName returns the EIP-712 domain name of the USDC contract +// deployed on chainID. The mainnet FiatToken deployments use "USD Coin", +// but Circle's Base Sepolia testnet deployment uses "USDC" — signing with +// the wrong name produces a different domain separator, so the contract +// (and any x402 facilitator) rejects the signature. Values verified +// against each contract's on-chain name() and DOMAIN_SEPARATOR(); they +// also match the per-network EIP-712 metadata in x402's asset registry +// (go/mechanisms/evm/constants.go). +// +// Unknown chains fall back to "USD Coin", preserving the previous +// behavior for custom deployments passed via token override. +func usdcDomainName(chainID uint64) string { + if chainID == ChainBaseSepolia { + return "USDC" + } + return "USD Coin" +} + // USDCDomain returns the EIP-712 Domain for USDC on chainID, ready to -// pass to EIP3009Digest. USDC's EIP-712 domain uses name="USD Coin" -// and version="2" on every chain (verified against the deployed FiatToken -// contracts). +// pass to EIP3009Digest. version="2" on every supported chain; the +// domain name is per-chain (see usdcDomainName). func USDCDomain(chainID uint64) (Domain, error) { addr, err := USDCAddress(chainID) if err != nil { return Domain{}, err } return Domain{ - Name: "USD Coin", + Name: usdcDomainName(chainID), Version: "2", ChainID: chainID, VerifyingContract: addr, diff --git a/pkg/evm/evm_test.go b/pkg/evm/evm_test.go index 4b2e8eb..34167d1 100644 --- a/pkg/evm/evm_test.go +++ b/pkg/evm/evm_test.go @@ -115,13 +115,19 @@ func TestTransferWithAuthorizationTypeHash(t *testing.T) { } func TestUSDCDomainKnownChains(t *testing.T) { - for _, chainID := range []uint64{ChainEthereumMainnet, ChainBaseMainnet, ChainBaseSepolia} { + wantName := map[uint64]string{ + ChainEthereumMainnet: "USD Coin", + ChainBaseMainnet: "USD Coin", + ChainPolygonMainnet: "USD Coin", + ChainBaseSepolia: "USDC", // Circle's testnet deployment differs + } + for chainID, name := range wantName { d, err := USDCDomain(chainID) if err != nil { t.Fatalf("USDCDomain(%d): %v", chainID, err) } - if d.Name != "USD Coin" || d.Version != "2" || d.ChainID != chainID { - t.Errorf("domain for chain %d wrong: %+v", chainID, d) + if d.Name != name || d.Version != "2" || d.ChainID != chainID { + t.Errorf("domain for chain %d wrong: %+v (want name %q)", chainID, d, name) } // Separator must be 32 bytes. sep := d.Separator() @@ -131,6 +137,30 @@ func TestUSDCDomainKnownChains(t *testing.T) { } } +// TestUSDCDomainSeparatorsMatchOnChain pins Domain.Separator() for every +// supported chain to the value the deployed USDC contract returns from +// DOMAIN_SEPARATOR() (fetched via eth_call, 2026-07-29). If any of these +// fail, signatures produced for that chain are unverifiable on-chain — +// this is the regression test for the Base Sepolia "USD Coin"/"USDC" +// domain-name mismatch. +func TestUSDCDomainSeparatorsMatchOnChain(t *testing.T) { + onChain := map[uint64]string{ + ChainEthereumMainnet: "06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335", + ChainBaseMainnet: "02fa7265e7c5d81118673727957699e4d68f74cd74b7db77da710fe8a2c7834f", + ChainPolygonMainnet: "caa2ce1a5703ccbe253a34eb3166df60a705c561b44b192061e28f2a985be2ca", + ChainBaseSepolia: "71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818", + } + for chainID, want := range onChain { + d, err := USDCDomain(chainID) + if err != nil { + t.Fatalf("USDCDomain(%d): %v", chainID, err) + } + if got := hex.EncodeToString(d.Separator()); got != want { + t.Errorf("chain %d separator %s, on-chain DOMAIN_SEPARATOR() is %s", chainID, got, want) + } + } +} + func TestUSDCDomainRejectsUnknownChain(t *testing.T) { _, err := USDCDomain(999999) if err == nil { diff --git a/pkg/evm/method.go b/pkg/evm/method.go index d5f7f9d..b5ef3e9 100644 --- a/pkg/evm/method.go +++ b/pkg/evm/method.go @@ -128,7 +128,7 @@ func (m *EVMMethod) Satisfy(_ context.Context, c payment.Contract) (payment.Rece Nonce: nonce, } domain := Domain{ - Name: "USD Coin", + Name: usdcDomainName(m.chainID), Version: "2", ChainID: m.chainID, VerifyingContract: m.token, @@ -225,7 +225,7 @@ func (m *EVMMethod) Verify(_ context.Context, c payment.Contract, r payment.Rece Nonce: nonce, } domain := Domain{ - Name: "USD Coin", + Name: usdcDomainName(p.ChainID), Version: "2", ChainID: p.ChainID, VerifyingContract: p.Token,