Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions internal/serviceoffercontroller/usdc_domain_consistency_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
16 changes: 10 additions & 6 deletions internal/x402/chains.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}

Expand Down
88 changes: 88 additions & 0 deletions internal/x402/chains_domain_test.go
Original file line number Diff line number Diff line change
@@ -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 <USDCAddress> "DOMAIN_SEPARATOR()(bytes32)" --rpc-url <chain-rpc>
//
// 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
}
2 changes: 1 addition & 1 deletion internal/x402/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading