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
12 changes: 12 additions & 0 deletions DEFAULT_ASSETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions go/.changes/unreleased/added-20260429-103814.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion go/http/avm_paywall_template.go

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion go/http/evm_paywall_template.go

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions go/http/paywall_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
33 changes: 30 additions & 3 deletions go/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -1029,7 +1038,8 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
amount: %.6f,
testnet: %t,
displayAmount: %.2f,
currentUrl: "%s"
currentUrl: "%s",
faucetUrls: %s
};
</script>`,
string(requirementsJSON),
Expand All @@ -1039,6 +1049,7 @@ func (s *x402HTTPResourceServer) generatePaywallHTML(paymentRequired x402.Paymen
testnet,
displayAmount,
html.EscapeString(currentURL),
marshalFaucetURLs(faucetURLs),
)

// Select template based on network
Expand Down Expand Up @@ -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 {
Expand All @@ -1115,7 +1128,8 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
amount: %.6f,
testnet: %t,
displayAmount: %.2f,
currentUrl: "%s"
currentUrl: "%s",
faucetUrls: %s
};
</script>`,
string(requirementsJSON),
Expand All @@ -1125,11 +1139,24 @@ func injectPaywallConfig(template string, paymentRequired types.PaymentRequired,
testnet,
displayAmount,
html.EscapeString(currentURL),
marshalFaucetURLs(faucetURLs),
)

return strings.Replace(template, "</head>", configScript+"\n</head>", 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
// ============================================================================
Expand Down
2 changes: 1 addition & 1 deletion go/http/svm_paywall_template.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions python/x402/changelog.d/2160.feature.md
Original file line number Diff line number Diff line change
@@ -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=...)`.
22 changes: 20 additions & 2 deletions python/x402/http/paywall/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,21 @@ 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,
"currentUrl": current_url,
"appName": app_name,
"appLogo": app_logo,
}
if faucet_urls:
x402_config["faucetUrls"] = faucet_urls
config_script = f"""
<script>
window.x402 = {htmlsafe_json_dumps(x402_config)};
Expand Down Expand Up @@ -226,18 +229,21 @@ 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,
"currentUrl": current_url,
"appName": app_name,
"appLogo": app_logo,
}
if faucet_urls:
x402_config["faucetUrls"] = faucet_urls
config_script = f"""
<script>
window.x402 = {htmlsafe_json_dumps(x402_config)};
Expand Down Expand Up @@ -294,6 +300,7 @@ class PaywallBuilder:
_app_logo: str = ""
_testnet: bool = True
_current_url: str = ""
_faucet_urls: dict[str, str] | None = None

def with_network(self, handler: PaywallNetworkHandler) -> PaywallBuilder:
"""Register a network-specific paywall handler.
Expand All @@ -313,6 +320,7 @@ def with_config(
app_logo: str = "",
testnet: bool = True,
current_url: str = "",
faucet_urls: dict[str, str] | None = None,
) -> PaywallBuilder:
"""Set configuration options for the paywall.

Expand All @@ -321,6 +329,7 @@ def with_config(
app_logo: Application logo URL.
testnet: Whether to use testnet (default: True).
current_url: URL of the protected resource.
faucet_urls: Per-chain override map keyed by CAIP-2 identifier.

Returns:
Self for method chaining.
Expand All @@ -332,6 +341,8 @@ def with_config(
self._testnet = testnet
if current_url:
self._current_url = current_url
if faucet_urls is not None:
self._faucet_urls = faucet_urls
return self

def build(self) -> PaywallProvider:
Expand All @@ -346,6 +357,7 @@ def build(self) -> PaywallProvider:
app_logo=self._app_logo,
testnet=self._testnet,
current_url=self._current_url,
faucet_urls=self._faucet_urls,
)


Expand All @@ -358,6 +370,7 @@ class PaywallProvider:
app_logo: str = ""
testnet: bool = True
current_url: str = ""
faucet_urls: dict[str, str] | None = None

def generate_html(
self,
Expand Down Expand Up @@ -386,6 +399,11 @@ def generate_html(
app_logo=config.app_logo if config and config.app_logo else self.app_logo,
testnet=config.testnet if config else self.testnet,
current_url=(config.current_url if config and config.current_url else self.current_url),
faucet_urls=(
config.faucet_urls
if config and config.faucet_urls is not None
else self.faucet_urls
),
)

# Find first handler that supports the payment requirements
Expand Down
2 changes: 1 addition & 1 deletion python/x402/http/paywall/avm_paywall_template.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion python/x402/http/paywall/evm_paywall_template.py

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion python/x402/http/paywall/svm_paywall_template.py

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion python/x402/http/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,20 @@ class ProcessSettleResult:

@dataclass
class PaywallConfig:
"""Configuration for paywall UI customization."""
"""Configuration for paywall UI customization.

``faucet_urls`` is a per-chain override keyed by CAIP-2 network identifier
(e.g. ``"eip155:84532"``). Wins over the paywall's curated default map for
the matching chain. Unmapped chains render "No faucet configured." rather
than a fallback link.
"""

app_name: str | None = None
app_logo: str | None = None
session_token_endpoint: str | None = None
current_url: str | None = None
testnet: bool = False
faucet_urls: dict[str, str] | None = None


# Dynamic function types (supports both sync and async callbacks)
Expand Down
Loading
Loading