diff --git a/DEFAULT_ASSETS.md b/DEFAULT_ASSETS.md
index 4e3f52078d..8b2142d585 100644
--- a/DEFAULT_ASSETS.md
+++ b/DEFAULT_ASSETS.md
@@ -105,6 +105,18 @@ See [CONTRIBUTING.md — Paywall Changes](CONTRIBUTING.md#paywall-changes) for t
Include the chain name and rationale for the asset selection. If the chain team has officially endorsed a stablecoin, mention that.
+## Paywall faucet link (recommended for testnets)
+
+The paywall renders a "Need {token} on {chain}? Request some here." link on testnet payment requirements. Without a configured faucet URL, the paywall renders "No faucet configured." instead.
+
+To provide a working faucet link for your testnet, add one line to `typescript/packages/http/paywall/src/faucetUrls.ts`:
+
+```typescript
+"eip155:YOUR_TESTNET_CHAIN_ID": "https://your-faucet-url",
+```
+
+Paywall-only file; recommended for testnet entries; N/A for mainnet (paywall faucet UI is testnet-gated). No cross-SDK lockstep required — the map is rendered exclusively by the TypeScript paywall bundle and is read by the Python and Go paywall handlers through the bundled template.
+
## Asset Selection Policy
The default asset is chosen **per chain** based on:
diff --git a/go/.changes/unreleased/added-20260429-103814.yaml b/go/.changes/unreleased/added-20260429-103814.yaml
new file mode 100644
index 0000000000..51dd41a191
--- /dev/null
+++ b/go/.changes/unreleased/added-20260429-103814.yaml
@@ -0,0 +1,3 @@
+kind: added
+body: 'Add a curated testnet faucet map to the paywall plus PaywallConfig.FaucetURLs (per-chain override keyed by CAIP-2). Unmapped chains render "No faucet configured." instead of a fallback link.'
+time: 2026-04-29T10:38:14.93683-04:00
diff --git a/go/http/avm_paywall_template.go b/go/http/avm_paywall_template.go
index 8c2a98602c..bf752ae5f3 100644
--- a/go/http/avm_paywall_template.go
+++ b/go/http/avm_paywall_template.go
@@ -2,4 +2,4 @@
package http
// AVMPaywallTemplate is the pre-built AVM paywall template with inlined CSS and JS
-const AVMPaywallTemplate = "
\n \n \n Payment Required\n \n \n \n \n "
+const AVMPaywallTemplate = "\n \n \n Payment Required\n \n \n \n \n "
diff --git a/go/http/evm_paywall_template.go b/go/http/evm_paywall_template.go
index d0b85a2c22..23c7996160 100644
--- a/go/http/evm_paywall_template.go
+++ b/go/http/evm_paywall_template.go
@@ -2,4 +2,4 @@
package http
// EVMPaywallTemplate is the pre-built EVM paywall template with inlined CSS and JS
-const EVMPaywallTemplate = "\n \n \n Payment Required\n \n \n \n \n "
+const EVMPaywallTemplate = "\n \n \n Payment Required\n \n \n \n \n "
diff --git a/go/http/paywall_test.go b/go/http/paywall_test.go
index ceefd937af..336cc933bc 100644
--- a/go/http/paywall_test.go
+++ b/go/http/paywall_test.go
@@ -322,4 +322,30 @@ func TestInjectPaywallConfig(t *testing.T) {
t.Error("expected resource URL as currentUrl fallback")
}
})
+
+ t.Run("injects FaucetURLs map when set", func(t *testing.T) {
+ config := &PaywallConfig{
+ FaucetURLs: map[string]string{
+ "eip155:84532": "https://example.com/base-sepolia",
+ "eip155:421614": "https://example.com/arb-sepolia",
+ },
+ }
+ got := injectPaywallConfig(template, paymentReq, config)
+ if !strings.Contains(got, "faucetUrls:") {
+ t.Errorf("expected faucetUrls in output, got %q", got)
+ }
+ if !strings.Contains(got, "https://example.com/base-sepolia") {
+ t.Errorf("expected per-chain faucet URL in output, got %q", got)
+ }
+ if !strings.Contains(got, "https://example.com/arb-sepolia") {
+ t.Errorf("expected per-chain faucet URL in output, got %q", got)
+ }
+ })
+
+ t.Run("emits faucetUrls: undefined when FaucetURLs unset", func(t *testing.T) {
+ got := injectPaywallConfig(template, paymentReq, nil)
+ if !strings.Contains(got, "faucetUrls: undefined") {
+ t.Errorf("expected faucetUrls: undefined in output, got %q", got)
+ }
+ })
}
diff --git a/go/http/server.go b/go/http/server.go
index 5632ce8772..28a682e858 100644
--- a/go/http/server.go
+++ b/go/http/server.go
@@ -45,12 +45,19 @@ type HTTPAdapter interface {
// Configuration Types
// ============================================================================
-// PaywallConfig configures the HTML paywall for browser requests
+// PaywallConfig configures the HTML paywall for browser requests.
+//
+// FaucetURLs is a per-chain override map keyed by CAIP-2 identifier (e.g.
+// "eip155:84532"). When set, the entry for the rendered chain wins over the
+// paywall's curated default. Unmapped chains render "No faucet configured."
+// rather than a fallback link.
type PaywallConfig struct {
AppName string `json:"appName,omitempty"`
AppLogo string `json:"appLogo,omitempty"`
CurrentURL string `json:"currentUrl,omitempty"`
Testnet bool `json:"testnet,omitempty"`
+ // FaucetURLs is a per-chain override keyed by CAIP-2 identifier.
+ FaucetURLs map[string]string `json:"faucetUrls,omitempty"`
}
// DynamicPayToFunc is a function that resolves payTo address dynamically based on request context
@@ -1005,12 +1012,14 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
appLogo := ""
testnet := false
currentURL := ""
+ var faucetURLs map[string]string
if config != nil {
appName = config.AppName
appLogo = config.AppLogo
testnet = config.Testnet
currentURL = config.CurrentURL
+ faucetURLs = config.FaucetURLs
}
// Use resource URL as currentUrl if not explicitly configured
@@ -1029,7 +1038,8 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
amount: %.6f,
testnet: %t,
displayAmount: %.2f,
- currentUrl: "%s"
+ currentUrl: "%s",
+ faucetUrls: %s
};
`,
string(requirementsJSON),
@@ -1039,6 +1049,7 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
testnet,
displayAmount,
html.EscapeString(currentURL),
+ marshalFaucetURLs(faucetURLs),
)
// Select template based on network
@@ -1093,12 +1104,14 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
appLogo := ""
testnet := false
currentURL := ""
+ var faucetURLs map[string]string
if config != nil {
appName = config.AppName
appLogo = config.AppLogo
testnet = config.Testnet
currentURL = config.CurrentURL
+ faucetURLs = config.FaucetURLs
}
if currentURL == "" && paymentRequired.Resource != nil {
@@ -1115,7 +1128,8 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
amount: %.6f,
testnet: %t,
displayAmount: %.2f,
- currentUrl: "%s"
+ currentUrl: "%s",
+ faucetUrls: %s
};
`,
string(requirementsJSON),
@@ -1125,11 +1139,24 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
testnet,
displayAmount,
html.EscapeString(currentURL),
+ marshalFaucetURLs(faucetURLs),
)
return strings.Replace(template, "", configScript+"\n", 1)
}
+// marshalFaucetURLs renders FaucetURLs as a JS literal: a JSON object or `undefined`.
+func marshalFaucetURLs(urls map[string]string) string {
+ if len(urls) == 0 {
+ return "undefined"
+ }
+ encoded, err := json.Marshal(urls)
+ if err != nil {
+ return "undefined"
+ }
+ return string(encoded)
+}
+
// ============================================================================
// Utility Functions
// ============================================================================
diff --git a/go/http/svm_paywall_template.go b/go/http/svm_paywall_template.go
index 4066fee5fa..ceb91bb4cb 100644
--- a/go/http/svm_paywall_template.go
+++ b/go/http/svm_paywall_template.go
@@ -2,4 +2,4 @@
package http
// SVMPaywallTemplate is the pre-built SVM paywall template with inlined CSS and JS
-const SVMPaywallTemplate = "\n \n \n Payment Required\n \n \n \n \n "
+const SVMPaywallTemplate = "\n \n \n Payment Required\n \n \n \n \n "
diff --git a/python/x402/changelog.d/2160.feature.md b/python/x402/changelog.d/2160.feature.md
new file mode 100644
index 0000000000..de0b597f64
--- /dev/null
+++ b/python/x402/changelog.d/2160.feature.md
@@ -0,0 +1 @@
+Added a curated testnet faucet map to the paywall plus `PaywallConfig.faucet_urls` (per-chain override keyed by CAIP-2). Unmapped chains render "No faucet configured." instead of a fallback link. Available via `PaywallBuilder.with_config(faucet_urls=...)`.
diff --git a/python/x402/http/paywall/__init__.py b/python/x402/http/paywall/__init__.py
index 26147ba100..050e024d39 100644
--- a/python/x402/http/paywall/__init__.py
+++ b/python/x402/http/paywall/__init__.py
@@ -149,11 +149,12 @@ def generate_html(
app_logo = config.app_logo if config else ""
testnet = config.testnet if config else True
current_url = config.current_url if config else ""
+ faucet_urls = config.faucet_urls if config else None
amount = _get_display_amount(payment_required)
payment_required_json = payment_required.model_dump(by_alias=True, exclude_none=True)
- x402_config = {
+ x402_config: dict[str, object] = {
"amount": amount,
"paymentRequired": payment_required_json,
"testnet": testnet,
@@ -161,6 +162,8 @@ def generate_html(
"appName": app_name,
"appLogo": app_logo,
}
+ if faucet_urls:
+ x402_config["faucetUrls"] = faucet_urls
config_script = f"""