diff --git a/internal/serviceoffercontroller/usdc_domain_consistency_test.go b/internal/serviceoffercontroller/usdc_domain_consistency_test.go new file mode 100644 index 00000000..46286e3b --- /dev/null +++ b/internal/serviceoffercontroller/usdc_domain_consistency_test.go @@ -0,0 +1,42 @@ +package serviceoffercontroller + +import ( + "testing" + + "github.com/ObolNetwork/obol-stack/internal/x402" +) + +// TestCatalogUSDCMatchesVerifierChain guards against the EIP-712 USDC domain +// name (and version) drifting between the TWO independent Go sources that must +// agree: the catalog renderer's defaultUSDCForNetwork (what /api/services.json +// advertises) and x402's chain registry (what the 402 advertises and the buyer +// signs under). They disagreed once — chains.go said "USD Coin" for base-sepolia +// while the catalog already said the correct "USDC" — which silently broke +// host-side EIP-3009 signatures against a real facilitator and kept recurring +// because each source was hand-maintained. +// +// x402's TestUSDCDomainSeparatorsMatchOnChain pins the registry to the on-chain +// value; this test pins the catalog and the registry to EACH OTHER, so a future +// edit to one without the other fails offline at `go test`. +func TestCatalogUSDCMatchesVerifierChain(t *testing.T) { + for _, net := range []string{"base", "base-sepolia", "ethereum"} { + t.Run(net, func(t *testing.T) { + cat, ok := defaultUSDCForNetwork(net) + if !ok || cat.EIP712Domain == nil { + t.Fatalf("catalog has no USDC EIP-712 domain for %q", net) + } + ci, err := x402.ResolveChainInfo(net) + if err != nil { + t.Fatalf("x402.ResolveChainInfo(%q): %v", net, err) + } + if cat.EIP712Domain.Name != ci.EIP3009Name { + 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\")", + net, cat.EIP712Domain.Name, ci.EIP3009Name) + } + if cat.EIP712Domain.Version != ci.EIP3009Version { + t.Errorf("%s EIP-712 version drift: catalog=%q vs verifier=%q", + net, cat.EIP712Domain.Version, ci.EIP3009Version) + } + }) + } +} diff --git a/internal/x402/chains.go b/internal/x402/chains.go index 47d2a451..2a6efd34 100644 --- a/internal/x402/chains.go +++ b/internal/x402/chains.go @@ -66,12 +66,16 @@ var ( } ChainBaseSepolia = ChainInfo{ - Name: "base-sepolia", - NetworkID: "base-sepolia", - CAIP2Network: "eip155:84532", - USDCAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", - Decimals: 6, - EIP3009Name: "USD Coin", + Name: "base-sepolia", + NetworkID: "base-sepolia", + CAIP2Network: "eip155:84532", + USDCAddress: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", + Decimals: 6, + // Base-Sepolia USDC is FiatTokenV2_2 whose EIP-712 domain name is + // "USDC", NOT the mainnet "USD Coin". Advertising "USD Coin" makes a + // real facilitator reject otherwise-valid signatures — the recurring + // base-sepolia "name" bug that a stub facilitator silently masks. + EIP3009Name: "USDC", EIP3009Version: "2", } diff --git a/internal/x402/chains_domain_test.go b/internal/x402/chains_domain_test.go new file mode 100644 index 00000000..b37a22ba --- /dev/null +++ b/internal/x402/chains_domain_test.go @@ -0,0 +1,88 @@ +package x402 + +import ( + "fmt" + "strconv" + "strings" + "testing" + + gethmath "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/signer/core/apitypes" +) + +// goldenUSDCDomainSeparators pins each chain's USDC EIP-712 DOMAIN_SEPARATOR as +// read from the live token contract: +// +// cast call "DOMAIN_SEPARATOR()(bytes32)" --rpc-url +// +// The domain separator is a deterministic function of the four fields a buyer +// signs under — (name, version, chainId, verifyingContract). Pinning it turns +// the recurring base-sepolia "USD Coin" vs "USDC" EIP-712 *name* bug into an +// OFFLINE `go test` failure: a wrong name yields a different separator, so an +// EIP-3009 signature built from this registry would be rejected by a real +// facilitator (FiatToken's SignatureChecker). The bug bit ~repeatedly because +// nothing tied the hand-maintained name string to the on-chain domain; this +// closes that loop. Capture and add a chain's value here as you verify it. +var goldenUSDCDomainSeparators = []struct { + name string + chain ChainInfo + golden string +}{ + // Base-Sepolia USDC is FiatTokenV2_2 — domain name "USDC", NOT "USD Coin". + {"base-sepolia", ChainBaseSepolia, "0x71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818"}, +} + +func TestUSDCDomainSeparatorsMatchOnChain(t *testing.T) { + for _, tc := range goldenUSDCDomainSeparators { + t.Run(tc.name, func(t *testing.T) { + got, err := usdcDomainSeparator(tc.chain) + if err != nil { + t.Fatalf("compute domain separator: %v", err) + } + if got != tc.golden { + t.Errorf("%s USDC EIP-712 domain separator = %s, want on-chain %s\n"+ + " registry has EIP3009Name=%q version=%q addr=%s — the name almost certainly\n"+ + " disagrees with the on-chain token domain (base-sepolia FiatTokenV2_2 is \"USDC\",\n"+ + " mainnet USDC is \"USD Coin\"). A real facilitator will reject signatures built here.", + tc.name, got, tc.golden, tc.chain.EIP3009Name, tc.chain.EIP3009Version, tc.chain.USDCAddress) + } + }) + } +} + +// usdcDomainSeparator computes the EIP-712 domain separator a buyer signs under +// for ci's USDC — the same (name, version, chainId, verifyingContract) tuple a +// conforming x402 buyer reads from the advertised registry — so this guards the +// exact value that reaches a facilitator. The chainId is parsed inline from the +// CAIP-2 network id to keep the guard self-contained. +func usdcDomainSeparator(ci ChainInfo) (string, error) { + netID := ci.CAIP2Network + if _, after, ok := strings.Cut(netID, ":"); ok { + netID = after + } + chainID, err := strconv.ParseInt(netID, 10, 64) + if err != nil { + return "", fmt.Errorf("parse chain id from %q: %w", ci.CAIP2Network, err) + } + td := apitypes.TypedData{ + Types: apitypes.Types{ + "EIP712Domain": { + {Name: "name", Type: "string"}, + {Name: "version", Type: "string"}, + {Name: "chainId", Type: "uint256"}, + {Name: "verifyingContract", Type: "address"}, + }, + }, + Domain: apitypes.TypedDataDomain{ + Name: ci.EIP3009Name, + Version: ci.EIP3009Version, + ChainId: gethmath.NewHexOrDecimal256(chainID), + VerifyingContract: ci.USDCAddress, + }, + } + sep, err := td.HashStruct("EIP712Domain", td.Domain.Map()) + if err != nil { + return "", err + } + return sep.String(), nil +} diff --git a/internal/x402/tokens.go b/internal/x402/tokens.go index 93b343ae..fc437ad8 100644 --- a/internal/x402/tokens.go +++ b/internal/x402/tokens.go @@ -44,7 +44,7 @@ type TokenEntry struct { var tokenRegistry = map[string]map[string]TokenEntry{ "USDC": { "base": {Address: ChainBaseMainnet.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USD Coin", EIP712Version: "2"}, - "base-sepolia": {Address: ChainBaseSepolia.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USD Coin", EIP712Version: "2"}, + "base-sepolia": {Address: ChainBaseSepolia.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USDC", EIP712Version: "2"}, "ethereum": {Address: ChainEthereumMainnet.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USD Coin", EIP712Version: "2"}, "polygon": {Address: ChainPolygonMainnet.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USD Coin", EIP712Version: "2"}, "polygon-amoy": {Address: ChainPolygonAmoy.USDCAddress, Symbol: "USDC", Decimals: 6, TransferMethod: "eip3009", EIP712Name: "USD Coin", EIP712Version: "2"},