Skip to content

Commit 19e7865

Browse files
committed
feat(paywall): chain-aware faucetUrl resolution across TS/Python/Go SDKs
Adds a 4-layer precedence chain for the testnet 'Need {token} on {chain}? Get some here' link in the paywall: 1. PaywallConfig.faucetUrls[caip2] per-chain consumer override 2. PaywallConfig.faucetUrl global consumer override 3. Chain registry default DEFAULT_STABLECOINS[caip2].faucetUrl (EVM), USDC_CONFIG[caip2].faucetUrl (AVM), inline build-time map (SVM) 4. Hardcoded fallback https://faucet.circle.com/ (EVM/SVM), Algorand testnet dispenser (AVM) SDK parity: TypeScript, Python, Go each expose PaywallConfig.{faucetUrl, faucetUrls} and AssetInfo.faucetUrl on EVM and SVM. Covers all three paywall mechanisms (EVM, SVM, AVM). Mainnet entries intentionally leave faucetUrl unset since the paywall faucet UI is testnet-gated and never renders the link on mainnet. Backwards compatibility: every layer is opt-in. Unconfigured callers see the same testnet link as today on every existing chain (the hardcoded fallback). No public API removed or renamed; pure additive change. Closes #2159
1 parent 12708ef commit 19e7865

44 files changed

Lines changed: 878 additions & 42 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: added
2+
body: 'Add SDK-configurable faucet URL plus chain-aware registry to the paywall: PaywallConfig gains FaucetURL (global override) and FaucetURLs (per-chain map keyed by CAIP-2); AssetInfo gains FaucetURL on EVM and SVM; testnet entries seeded.'
3+
time: 2026-04-29T10:38:14.93683-04:00

go/http/avm_paywall_template.go

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

go/http/evm_paywall_template.go

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

go/http/paywall_test.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"strings"
55
"testing"
66

7+
"github.com/x402-foundation/x402/go/mechanisms/evm"
8+
svm "github.com/x402-foundation/x402/go/mechanisms/svm"
79
"github.com/x402-foundation/x402/go/types"
810
)
911

@@ -322,4 +324,102 @@ func TestInjectPaywallConfig(t *testing.T) {
322324
t.Error("expected resource URL as currentUrl fallback")
323325
}
324326
})
327+
328+
t.Run("injects FaucetURL when set", func(t *testing.T) {
329+
config := &PaywallConfig{FaucetURL: "https://example.com/faucet"}
330+
got := injectPaywallConfig(template, paymentReq, config)
331+
if !strings.Contains(got, `faucetUrl: "https://example.com/faucet"`) {
332+
t.Errorf("expected faucetUrl literal in output, got %q", got)
333+
}
334+
})
335+
336+
t.Run("emits faucetUrl: undefined when FaucetURL unset", func(t *testing.T) {
337+
got := injectPaywallConfig(template, paymentReq, nil)
338+
if !strings.Contains(got, "faucetUrl: undefined") {
339+
t.Errorf("expected faucetUrl: undefined in output, got %q", got)
340+
}
341+
})
342+
343+
t.Run("injects FaucetURLs map when set", func(t *testing.T) {
344+
config := &PaywallConfig{
345+
FaucetURLs: map[string]string{
346+
"eip155:84532": "https://example.com/base-sepolia",
347+
"eip155:421614": "https://example.com/arb-sepolia",
348+
},
349+
}
350+
got := injectPaywallConfig(template, paymentReq, config)
351+
if !strings.Contains(got, "faucetUrls:") {
352+
t.Errorf("expected faucetUrls in output, got %q", got)
353+
}
354+
if !strings.Contains(got, "https://example.com/base-sepolia") {
355+
t.Errorf("expected base-sepolia URL in output, got %q", got)
356+
}
357+
if !strings.Contains(got, "https://example.com/arb-sepolia") {
358+
t.Errorf("expected arb-sepolia URL in output, got %q", got)
359+
}
360+
})
361+
362+
t.Run("emits faucetUrls: undefined when FaucetURLs unset", func(t *testing.T) {
363+
got := injectPaywallConfig(template, paymentReq, nil)
364+
if !strings.Contains(got, "faucetUrls: undefined") {
365+
t.Errorf("expected faucetUrls: undefined in output, got %q", got)
366+
}
367+
})
368+
}
369+
370+
// --- Registry seed tests ---
371+
372+
func TestEVMRegistryFaucetURLSeeds(t *testing.T) {
373+
// Pin the EVM registry seed values for chain-aware faucet URLs. Mirrors
374+
// the TS-side `FAUCET_URLS` drift test against `DEFAULT_STABLECOINS`.
375+
expected := map[string]string{
376+
"eip155:84532": "https://faucet.circle.com/",
377+
"eip155:421614": "https://faucet.circle.com/",
378+
"eip155:31611": "https://faucet.test.mezo.org/",
379+
"eip155:2201": "https://faucet.stable.xyz/faucet",
380+
}
381+
for caip2, want := range expected {
382+
config, ok := evm.NetworkConfigs[caip2]
383+
if !ok {
384+
t.Errorf("missing EVM NetworkConfig for %q", caip2)
385+
continue
386+
}
387+
if config.DefaultAsset.FaucetURL != want {
388+
t.Errorf("EVM FaucetURL for %q: got %q, want %q", caip2, config.DefaultAsset.FaucetURL, want)
389+
}
390+
}
391+
}
392+
393+
func TestSVMRegistryFaucetURLSeeds(t *testing.T) {
394+
// Pin the SVM registry seed values. Both Solana devnet and testnet point
395+
// at Circle's faucet (which mints USDC on those networks).
396+
expected := map[string]string{
397+
svm.SolanaDevnetCAIP2: "https://faucet.circle.com/",
398+
svm.SolanaTestnetCAIP2: "https://faucet.circle.com/",
399+
}
400+
for caip2, want := range expected {
401+
config, ok := svm.NetworkConfigs[caip2]
402+
if !ok {
403+
t.Errorf("missing SVM NetworkConfig for %q", caip2)
404+
continue
405+
}
406+
if config.DefaultAsset.FaucetURL != want {
407+
t.Errorf("SVM FaucetURL for %q: got %q, want %q", caip2, config.DefaultAsset.FaucetURL, want)
408+
}
409+
}
410+
}
411+
412+
func TestEVMMainnetEntriesHaveNoFaucetURL(t *testing.T) {
413+
// Mainnet entries leave FaucetURL empty by convention — the paywall
414+
// faucet UI is testnet-gated and mainnet entries never render the link.
415+
mainnets := []string{"eip155:8453", "eip155:42161", "eip155:137"}
416+
for _, caip2 := range mainnets {
417+
config, ok := evm.NetworkConfigs[caip2]
418+
if !ok {
419+
continue // not seeded; nothing to assert
420+
}
421+
if config.DefaultAsset.FaucetURL != "" {
422+
t.Errorf("mainnet %q has unexpected FaucetURL %q", caip2, config.DefaultAsset.FaucetURL)
423+
}
424+
}
325425
}

go/http/server.go

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,24 @@ type HTTPAdapter interface {
4444
// Configuration Types
4545
// ============================================================================
4646

47-
// PaywallConfig configures the HTML paywall for browser requests
47+
// PaywallConfig configures the HTML paywall for browser requests.
48+
//
49+
// Faucet URL resolution precedence at render time (top wins):
50+
// 1. FaucetURLs[caip2] — per-chain override
51+
// 2. FaucetURL — global override
52+
// 3. The mechanism's curated chain registry (e.g. NetworkConfigs for EVM)
53+
// when the selected chain has a FaucetURL populated
54+
// 4. https://faucet.circle.com/ — final hardcoded fallback
4855
type PaywallConfig struct {
4956
AppName string `json:"appName,omitempty"`
5057
AppLogo string `json:"appLogo,omitempty"`
5158
CurrentURL string `json:"currentUrl,omitempty"`
5259
Testnet bool `json:"testnet,omitempty"`
60+
// FaucetURL is a global override applied to every chain in the paywall.
61+
FaucetURL string `json:"faucetUrl,omitempty"`
62+
// FaucetURLs is a per-chain override map keyed by CAIP-2 identifier
63+
// (e.g. "eip155:84532"). Wins over FaucetURL for the selected chain.
64+
FaucetURLs map[string]string `json:"faucetUrls,omitempty"`
5365
}
5466

5567
// DynamicPayToFunc is a function that resolves payTo address dynamically based on request context
@@ -962,12 +974,16 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
962974
appLogo := ""
963975
testnet := false
964976
currentURL := ""
977+
faucetURL := ""
978+
var faucetURLs map[string]string
965979

966980
if config != nil {
967981
appName = config.AppName
968982
appLogo = config.AppLogo
969983
testnet = config.Testnet
970984
currentURL = config.CurrentURL
985+
faucetURL = config.FaucetURL
986+
faucetURLs = config.FaucetURLs
971987
}
972988

973989
// Use resource URL as currentUrl if not explicitly configured
@@ -986,7 +1002,9 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
9861002
amount: %.6f,
9871003
testnet: %t,
9881004
displayAmount: %.2f,
989-
currentUrl: "%s"
1005+
currentUrl: "%s",
1006+
faucetUrl: %s,
1007+
faucetUrls: %s
9901008
};
9911009
</script>`,
9921010
string(requirementsJSON),
@@ -996,6 +1014,8 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
9961014
testnet,
9971015
displayAmount,
9981016
html.EscapeString(currentURL),
1017+
marshalFaucetURL(faucetURL),
1018+
marshalFaucetURLs(faucetURLs),
9991019
)
10001020

10011021
// Select template based on network
@@ -1050,12 +1070,16 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
10501070
appLogo := ""
10511071
testnet := false
10521072
currentURL := ""
1073+
faucetURL := ""
1074+
var faucetURLs map[string]string
10531075

10541076
if config != nil {
10551077
appName = config.AppName
10561078
appLogo = config.AppLogo
10571079
testnet = config.Testnet
10581080
currentURL = config.CurrentURL
1081+
faucetURL = config.FaucetURL
1082+
faucetURLs = config.FaucetURLs
10591083
}
10601084

10611085
if currentURL == "" && paymentRequired.Resource != nil {
@@ -1072,7 +1096,9 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
10721096
amount: %.6f,
10731097
testnet: %t,
10741098
displayAmount: %.2f,
1075-
currentUrl: "%s"
1099+
currentUrl: "%s",
1100+
faucetUrl: %s,
1101+
faucetUrls: %s
10761102
};
10771103
</script>`,
10781104
string(requirementsJSON),
@@ -1082,11 +1108,37 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
10821108
testnet,
10831109
displayAmount,
10841110
html.EscapeString(currentURL),
1111+
marshalFaucetURL(faucetURL),
1112+
marshalFaucetURLs(faucetURLs),
10851113
)
10861114

10871115
return strings.Replace(template, "</head>", configScript+"\n</head>", 1)
10881116
}
10891117

1118+
// marshalFaucetURL renders FaucetURL as a JS literal: a JSON-quoted string or `undefined`.
1119+
func marshalFaucetURL(url string) string {
1120+
if url == "" {
1121+
return "undefined"
1122+
}
1123+
encoded, err := json.Marshal(url)
1124+
if err != nil {
1125+
return "undefined"
1126+
}
1127+
return string(encoded)
1128+
}
1129+
1130+
// marshalFaucetURLs renders FaucetURLs as a JS literal: a JSON object or `undefined`.
1131+
func marshalFaucetURLs(urls map[string]string) string {
1132+
if len(urls) == 0 {
1133+
return "undefined"
1134+
}
1135+
encoded, err := json.Marshal(urls)
1136+
if err != nil {
1137+
return "undefined"
1138+
}
1139+
return string(encoded)
1140+
}
1141+
10901142
// ============================================================================
10911143
// Utility Functions
10921144
// ============================================================================

go/http/svm_paywall_template.go

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

go/mechanisms/evm/constants.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,11 @@ var (
106106
"eip155:84532": {
107107
ChainID: ChainIDBaseSepolia,
108108
DefaultAsset: AssetInfo{
109-
Address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // USDC on Base Sepolia
110-
Name: "USDC",
111-
Version: "2",
112-
Decimals: DefaultDecimals,
109+
Address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // USDC on Base Sepolia
110+
Name: "USDC",
111+
Version: "2",
112+
Decimals: DefaultDecimals,
113+
FaucetURL: "https://faucet.circle.com/",
113114
},
114115
},
115116
// MegaETH Mainnet (uses Permit2 instead of EIP-3009, supports EIP-2612)
@@ -144,6 +145,7 @@ var (
144145
Decimals: 18,
145146
AssetTransferMethod: AssetTransferMethodPermit2,
146147
SupportsEip2612: true,
148+
FaucetURL: "https://faucet.test.mezo.org/",
147149
},
148150
},
149151
// Stable Mainnet
@@ -160,10 +162,11 @@ var (
160162
"eip155:2201": {
161163
ChainID: ChainIDStableTestnet,
162164
DefaultAsset: AssetInfo{
163-
Address: "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9", // USDT0 on Stable Testnet
164-
Name: "USDT0",
165-
Version: "1",
166-
Decimals: DefaultDecimals,
165+
Address: "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9", // USDT0 on Stable Testnet
166+
Name: "USDT0",
167+
Version: "1",
168+
Decimals: DefaultDecimals,
169+
FaucetURL: "https://faucet.stable.xyz/faucet",
167170
},
168171
},
169172
// Polygon Mainnet
@@ -190,10 +193,11 @@ var (
190193
"eip155:421614": {
191194
ChainID: ChainIDArbSepolia,
192195
DefaultAsset: AssetInfo{
193-
Address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", // USDC on ArbSepolia
194-
Name: "USD Coin",
195-
Version: "2",
196-
Decimals: DefaultDecimals,
196+
Address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", // USDC on ArbSepolia
197+
Name: "USD Coin",
198+
Version: "2",
199+
Decimals: DefaultDecimals,
200+
FaucetURL: "https://faucet.circle.com/",
197201
},
198202
},
199203
}

go/mechanisms/evm/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,12 @@ type AssetInfo struct {
303303
Decimals int
304304
AssetTransferMethod AssetTransferMethod
305305
SupportsEip2612 bool
306+
// FaucetURL is the optional faucet URL for this chain's default asset.
307+
// Surfaces in the paywall's testnet "Need {token} on {chain}? Get some
308+
// here." link when no consumer override is supplied. Only meaningful for
309+
// testnet chains (mainnet entries leave this empty since the paywall
310+
// faucet UI is testnet-gated).
311+
FaucetURL string
306312
}
307313

308314
// NetworkConfig contains network-specific configuration

go/mechanisms/svm/constants.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,21 @@ var (
8686
CAIP2: SolanaDevnetCAIP2,
8787
RPCURL: "https://api.devnet.solana.com",
8888
DefaultAsset: AssetInfo{
89-
Address: USDCDevnetAddress,
90-
Symbol: "USDC",
91-
Decimals: DefaultDecimals,
89+
Address: USDCDevnetAddress,
90+
Symbol: "USDC",
91+
Decimals: DefaultDecimals,
92+
FaucetURL: "https://faucet.circle.com/",
9293
},
9394
},
9495
SolanaTestnetCAIP2: {
9596
Name: "Solana Testnet",
9697
CAIP2: SolanaTestnetCAIP2,
9798
RPCURL: "https://api.testnet.solana.com",
9899
DefaultAsset: AssetInfo{
99-
Address: USDCTestnetAddress,
100-
Symbol: "USDC",
101-
Decimals: DefaultDecimals,
100+
Address: USDCTestnetAddress,
101+
Symbol: "USDC",
102+
Decimals: DefaultDecimals,
103+
FaucetURL: "https://faucet.circle.com/",
102104
},
103105
},
104106
}

go/mechanisms/svm/types.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ type AssetInfo struct {
5959
Address string // Mint address
6060
Symbol string // Token symbol (e.g., "USDC")
6161
Decimals int // Token decimals
62+
// FaucetURL is the optional faucet URL for this network's default asset.
63+
// Surfaces in the paywall's testnet "Need {token} on {chain}? Get some
64+
// here." link when no consumer override is supplied. Only meaningful for
65+
// non-mainnet networks (mainnet entries leave this empty since the
66+
// paywall faucet UI is testnet-gated).
67+
FaucetURL string
6268
}
6369

6470
// NetworkConfig contains network-specific configuration

0 commit comments

Comments
 (0)