Skip to content

Commit b11330c

Browse files
committed
fix(x402): base-sepolia USDC EIP-712 domain name is "USDC", with offline guards
Base-Sepolia USDC (FiatTokenV2_2) signs its EIP-712 domain under name "USDC", not the mainnet "USD Coin" — verified: the on-chain DOMAIN_SEPARATOR() equals the domain built with "USDC". chains.go advertised "USD Coin", so the 402 a standalone seller emits made every host-side EIP-3009 signature fail a REAL facilitator (the cluster buyer buy.py and the catalog renderer already hardcoded "USDC", which is why only host-side buyers broke and the stub facilitator masked it). Two offline guards so it cannot recur — the recurring root cause was the name being hand-maintained in several independent places that drifted: TestUSDCDomainSeparatorsMatchOnChain pins each chain to its captured on-chain DOMAIN_SEPARATOR (via the same apitypes path the signer uses); TestCatalogUSDCMatchesVerifierChain pins the catalog renderer and the x402 registry to each other. Surfaced by flows/p2p-surface-smoke.sh against a live x402-rs facilitator.
1 parent c5beb5c commit b11330c

3 files changed

Lines changed: 135 additions & 6 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package serviceoffercontroller
2+
3+
import (
4+
"testing"
5+
6+
"github.com/ObolNetwork/obol-stack/internal/x402"
7+
)
8+
9+
// TestCatalogUSDCMatchesVerifierChain guards against the EIP-712 USDC domain
10+
// name (and version) drifting between the TWO independent Go sources that must
11+
// agree: the catalog renderer's defaultUSDCForNetwork (what /api/services.json
12+
// advertises) and x402's chain registry (what the 402 advertises and the buyer
13+
// signs under). They disagreed once — chains.go said "USD Coin" for base-sepolia
14+
// while the catalog already said the correct "USDC" — which silently broke
15+
// host-side EIP-3009 signatures against a real facilitator and kept recurring
16+
// because each source was hand-maintained.
17+
//
18+
// x402's TestUSDCDomainSeparatorsMatchOnChain pins the registry to the on-chain
19+
// value; this test pins the catalog and the registry to EACH OTHER, so a future
20+
// edit to one without the other fails offline at `go test`.
21+
func TestCatalogUSDCMatchesVerifierChain(t *testing.T) {
22+
for _, net := range []string{"base", "base-sepolia", "ethereum"} {
23+
t.Run(net, func(t *testing.T) {
24+
cat, ok := defaultUSDCForNetwork(net)
25+
if !ok || cat.EIP712Domain == nil {
26+
t.Fatalf("catalog has no USDC EIP-712 domain for %q", net)
27+
}
28+
ci, err := x402.ResolveChainInfo(net)
29+
if err != nil {
30+
t.Fatalf("x402.ResolveChainInfo(%q): %v", net, err)
31+
}
32+
if cat.EIP712Domain.Name != ci.EIP3009Name {
33+
t.Errorf("%s EIP-712 name drift: catalog=%q vs verifier=%q — both must equal the on-chain token domain (base-sepolia is \"USDC\", mainnet is \"USD Coin\")",
34+
net, cat.EIP712Domain.Name, ci.EIP3009Name)
35+
}
36+
if cat.EIP712Domain.Version != ci.EIP3009Version {
37+
t.Errorf("%s EIP-712 version drift: catalog=%q vs verifier=%q",
38+
net, cat.EIP712Domain.Version, ci.EIP3009Version)
39+
}
40+
})
41+
}
42+
}

internal/x402/chains.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,19 @@ var (
6666
}
6767

6868
ChainBaseSepolia = ChainInfo{
69-
Name: "base-sepolia",
70-
NetworkID: "base-sepolia",
71-
CAIP2Network: "eip155:84532",
72-
USDCAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
73-
Decimals: 6,
74-
EIP3009Name: "USD Coin",
69+
Name: "base-sepolia",
70+
NetworkID: "base-sepolia",
71+
CAIP2Network: "eip155:84532",
72+
USDCAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
73+
Decimals: 6,
74+
// Base-Sepolia USDC is FiatTokenV2_2 whose EIP-712 domain name is "USDC"
75+
// (verified: on-chain DOMAIN_SEPARATOR() == keccak of the domain built
76+
// with "USDC"), NOT the mainnet "USD Coin". Advertising "USD Coin" makes
77+
// every EIP-3009 signature fail FiatToken's SignatureChecker against a
78+
// REAL facilitator — the recurring base-sepolia "name" bug that the stub
79+
// facilitator silently masked. TestUSDCDomainSeparatorsMatchOnChain pins
80+
// this so it can never regress.
81+
EIP3009Name: "USDC",
7582
EIP3009Version: "2",
7683
}
7784

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package x402
2+
3+
import (
4+
"testing"
5+
6+
gethmath "github.com/ethereum/go-ethereum/common/math"
7+
"github.com/ethereum/go-ethereum/signer/core/apitypes"
8+
)
9+
10+
// goldenUSDCDomainSeparators pins each chain's USDC EIP-712 DOMAIN_SEPARATOR as
11+
// read from the live token contract:
12+
//
13+
// cast call <USDCAddress> "DOMAIN_SEPARATOR()(bytes32)" --rpc-url <chain-rpc>
14+
//
15+
// The domain separator is a deterministic function of the four fields a buyer
16+
// signs under — (name, version, chainId, verifyingContract). Pinning it turns
17+
// the recurring base-sepolia "USD Coin" vs "USDC" EIP-712 *name* bug into an
18+
// OFFLINE `go test` failure: a wrong name yields a different separator, so an
19+
// EIP-3009 signature built from this registry would be rejected by a real
20+
// facilitator (FiatToken's SignatureChecker). The bug bit ~repeatedly because
21+
// nothing tied the hand-maintained name string to the on-chain domain; this
22+
// closes that loop. Capture and add a chain's value here as you verify it.
23+
var goldenUSDCDomainSeparators = []struct {
24+
name string
25+
chain ChainInfo
26+
golden string
27+
}{
28+
// Base-Sepolia USDC is FiatTokenV2_2 — domain name "USDC", NOT "USD Coin".
29+
{"base-sepolia", ChainBaseSepolia, "0x71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818"},
30+
}
31+
32+
func TestUSDCDomainSeparatorsMatchOnChain(t *testing.T) {
33+
for _, tc := range goldenUSDCDomainSeparators {
34+
t.Run(tc.name, func(t *testing.T) {
35+
got, err := usdcDomainSeparator(tc.chain)
36+
if err != nil {
37+
t.Fatalf("compute domain separator: %v", err)
38+
}
39+
if got != tc.golden {
40+
t.Errorf("%s USDC EIP-712 domain separator = %s, want on-chain %s\n"+
41+
" registry has EIP3009Name=%q version=%q addr=%s — the name almost certainly\n"+
42+
" disagrees with the on-chain token domain (base-sepolia FiatTokenV2_2 is \"USDC\",\n"+
43+
" mainnet USDC is \"USD Coin\"). A real facilitator will reject signatures built here.",
44+
tc.name, got, tc.golden, tc.chain.EIP3009Name, tc.chain.EIP3009Version, tc.chain.USDCAddress)
45+
}
46+
})
47+
}
48+
}
49+
50+
// usdcDomainSeparator computes the EIP-712 domain separator a buyer signs under
51+
// for ci's USDC via the SAME apitypes path SignExactPayment uses, so this guards
52+
// the exact value that reaches a facilitator — not a re-derivation that could
53+
// drift from the signer.
54+
func usdcDomainSeparator(ci ChainInfo) (string, error) {
55+
chainID, err := chainIDFromNetwork(ci.CAIP2Network)
56+
if err != nil {
57+
return "", err
58+
}
59+
td := apitypes.TypedData{
60+
Types: apitypes.Types{
61+
"EIP712Domain": {
62+
{Name: "name", Type: "string"},
63+
{Name: "version", Type: "string"},
64+
{Name: "chainId", Type: "uint256"},
65+
{Name: "verifyingContract", Type: "address"},
66+
},
67+
},
68+
Domain: apitypes.TypedDataDomain{
69+
Name: ci.EIP3009Name,
70+
Version: ci.EIP3009Version,
71+
ChainId: gethmath.NewHexOrDecimal256(chainID),
72+
VerifyingContract: ci.USDCAddress,
73+
},
74+
}
75+
sep, err := td.HashStruct("EIP712Domain", td.Domain.Map())
76+
if err != nil {
77+
return "", err
78+
}
79+
return sep.String(), nil
80+
}

0 commit comments

Comments
 (0)