Skip to content

Commit 2c1bbea

Browse files
authored
fix(evm): Base Sepolia USDC EIP-712 domain name is "USDC", not "USD Coin" (#30)
1 parent e0e1bec commit 2c1bbea

3 files changed

Lines changed: 56 additions & 9 deletions

File tree

pkg/evm/chains.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,34 @@ func KnownChainIDs() []uint64 {
6161
}
6262
}
6363

64+
// usdcDomainName returns the EIP-712 domain name of the USDC contract
65+
// deployed on chainID. The mainnet FiatToken deployments use "USD Coin",
66+
// but Circle's Base Sepolia testnet deployment uses "USDC" — signing with
67+
// the wrong name produces a different domain separator, so the contract
68+
// (and any x402 facilitator) rejects the signature. Values verified
69+
// against each contract's on-chain name() and DOMAIN_SEPARATOR(); they
70+
// also match the per-network EIP-712 metadata in x402's asset registry
71+
// (go/mechanisms/evm/constants.go).
72+
//
73+
// Unknown chains fall back to "USD Coin", preserving the previous
74+
// behavior for custom deployments passed via token override.
75+
func usdcDomainName(chainID uint64) string {
76+
if chainID == ChainBaseSepolia {
77+
return "USDC"
78+
}
79+
return "USD Coin"
80+
}
81+
6482
// USDCDomain returns the EIP-712 Domain for USDC on chainID, ready to
65-
// pass to EIP3009Digest. USDC's EIP-712 domain uses name="USD Coin"
66-
// and version="2" on every chain (verified against the deployed FiatToken
67-
// contracts).
83+
// pass to EIP3009Digest. version="2" on every supported chain; the
84+
// domain name is per-chain (see usdcDomainName).
6885
func USDCDomain(chainID uint64) (Domain, error) {
6986
addr, err := USDCAddress(chainID)
7087
if err != nil {
7188
return Domain{}, err
7289
}
7390
return Domain{
74-
Name: "USD Coin",
91+
Name: usdcDomainName(chainID),
7592
Version: "2",
7693
ChainID: chainID,
7794
VerifyingContract: addr,

pkg/evm/evm_test.go

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,13 +115,19 @@ func TestTransferWithAuthorizationTypeHash(t *testing.T) {
115115
}
116116

117117
func TestUSDCDomainKnownChains(t *testing.T) {
118-
for _, chainID := range []uint64{ChainEthereumMainnet, ChainBaseMainnet, ChainBaseSepolia} {
118+
wantName := map[uint64]string{
119+
ChainEthereumMainnet: "USD Coin",
120+
ChainBaseMainnet: "USD Coin",
121+
ChainPolygonMainnet: "USD Coin",
122+
ChainBaseSepolia: "USDC", // Circle's testnet deployment differs
123+
}
124+
for chainID, name := range wantName {
119125
d, err := USDCDomain(chainID)
120126
if err != nil {
121127
t.Fatalf("USDCDomain(%d): %v", chainID, err)
122128
}
123-
if d.Name != "USD Coin" || d.Version != "2" || d.ChainID != chainID {
124-
t.Errorf("domain for chain %d wrong: %+v", chainID, d)
129+
if d.Name != name || d.Version != "2" || d.ChainID != chainID {
130+
t.Errorf("domain for chain %d wrong: %+v (want name %q)", chainID, d, name)
125131
}
126132
// Separator must be 32 bytes.
127133
sep := d.Separator()
@@ -131,6 +137,30 @@ func TestUSDCDomainKnownChains(t *testing.T) {
131137
}
132138
}
133139

140+
// TestUSDCDomainSeparatorsMatchOnChain pins Domain.Separator() for every
141+
// supported chain to the value the deployed USDC contract returns from
142+
// DOMAIN_SEPARATOR() (fetched via eth_call, 2026-07-29). If any of these
143+
// fail, signatures produced for that chain are unverifiable on-chain —
144+
// this is the regression test for the Base Sepolia "USD Coin"/"USDC"
145+
// domain-name mismatch.
146+
func TestUSDCDomainSeparatorsMatchOnChain(t *testing.T) {
147+
onChain := map[uint64]string{
148+
ChainEthereumMainnet: "06c37168a7db5138defc7866392bb87a741f9b3d104deb5094588ce041cae335",
149+
ChainBaseMainnet: "02fa7265e7c5d81118673727957699e4d68f74cd74b7db77da710fe8a2c7834f",
150+
ChainPolygonMainnet: "caa2ce1a5703ccbe253a34eb3166df60a705c561b44b192061e28f2a985be2ca",
151+
ChainBaseSepolia: "71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818",
152+
}
153+
for chainID, want := range onChain {
154+
d, err := USDCDomain(chainID)
155+
if err != nil {
156+
t.Fatalf("USDCDomain(%d): %v", chainID, err)
157+
}
158+
if got := hex.EncodeToString(d.Separator()); got != want {
159+
t.Errorf("chain %d separator %s, on-chain DOMAIN_SEPARATOR() is %s", chainID, got, want)
160+
}
161+
}
162+
}
163+
134164
func TestUSDCDomainRejectsUnknownChain(t *testing.T) {
135165
_, err := USDCDomain(999999)
136166
if err == nil {

pkg/evm/method.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func (m *EVMMethod) Satisfy(_ context.Context, c payment.Contract) (payment.Rece
128128
Nonce: nonce,
129129
}
130130
domain := Domain{
131-
Name: "USD Coin",
131+
Name: usdcDomainName(m.chainID),
132132
Version: "2",
133133
ChainID: m.chainID,
134134
VerifyingContract: m.token,
@@ -225,7 +225,7 @@ func (m *EVMMethod) Verify(_ context.Context, c payment.Contract, r payment.Rece
225225
Nonce: nonce,
226226
}
227227
domain := Domain{
228-
Name: "USD Coin",
228+
Name: usdcDomainName(p.ChainID),
229229
Version: "2",
230230
ChainID: p.ChainID,
231231
VerifyingContract: p.Token,

0 commit comments

Comments
 (0)