From 7ca399b18387edaad89475ff1f901aa71b5f68c9 Mon Sep 17 00:00:00 2001 From: "Ryan R. Fox" Date: Fri, 15 May 2026 20:08:54 -0400 Subject: [PATCH] refactor(paywall): tighten faucet refactor per forensic review + SVM-dev framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address forensic review-quorum findings and conform SVM paywall to upstream pattern: - Drop dead solana:TESTNET FAUCET_URLS entry (Circle faucet does not dispense USDC on Solana Testnet; Option B renders "No faucet configured." for any unmapped chain). - Revert all Solana Testnet recognition added by prior refactor pass (SOLANA_NETWORK_REFS.TESTNET constant, getNetworkDisplayName branch, isTestnetNetwork OR-branch). The paywall now recognizes one non-mainnet SVM network — Devnet — matching upstream/main. No SVM dev expects paywall Testnet support; upstream doesn't provide it. - Unify "Need {tokenName} on {chainName}?" copy in SolanaPaywall and AvmPaywall payment-required headers (were hardcoded "USDC"; matches EvmPaywall). Closes #2159 --- DEFAULT_ASSETS.md | 12 ++ .../unreleased/added-20260429-103814.yaml | 3 + go/http/avm_paywall_template.go | 2 +- go/http/evm_paywall_template.go | 2 +- go/http/paywall_test.go | 26 ++++ go/http/server.go | 33 +++- go/http/svm_paywall_template.go | 2 +- python/x402/changelog.d/2160.feature.md | 1 + python/x402/http/paywall/__init__.py | 22 ++- .../x402/http/paywall/avm_paywall_template.py | 2 +- .../x402/http/paywall/evm_paywall_template.py | 2 +- .../x402/http/paywall/svm_paywall_template.py | 2 +- python/x402/http/types.py | 9 +- python/x402/tests/unit/http/test_paywall.py | 141 ++++++++++++++++++ .../.changeset/paywall-faucet-url-registry.md | 5 + .../http/paywall/src/avm/AvmPaywall.tsx | 36 +++-- .../http/paywall/src/avm/gen/template.ts | 2 +- .../packages/http/paywall/src/avm/index.ts | 1 + .../packages/http/paywall/src/avm/paywall.ts | 5 +- .../http/paywall/src/evm/EvmPaywall.tsx | 25 +++- .../http/paywall/src/evm/gen/template.ts | 2 +- .../packages/http/paywall/src/evm/index.ts | 1 + .../packages/http/paywall/src/evm/paywall.ts | 5 +- .../packages/http/paywall/src/faucetUrls.ts | 37 +++++ .../http/paywall/src/network-handlers.test.ts | 36 +++++ .../http/paywall/src/svm/SolanaPaywall.tsx | 31 ++-- .../http/paywall/src/svm/gen/template.ts | 2 +- .../packages/http/paywall/src/svm/index.ts | 1 + .../packages/http/paywall/src/svm/paywall.ts | 5 +- typescript/packages/http/paywall/src/types.ts | 9 ++ .../packages/http/paywall/src/window.d.ts | 1 + 31 files changed, 412 insertions(+), 51 deletions(-) create mode 100644 go/.changes/unreleased/added-20260429-103814.yaml create mode 100644 python/x402/changelog.d/2160.feature.md create mode 100644 python/x402/tests/unit/http/test_paywall.py create mode 100644 typescript/.changeset/paywall-faucet-url-registry.md create mode 100644 typescript/packages/http/paywall/src/faucetUrls.ts 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""" ' +AVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/python/x402/http/paywall/evm_paywall_template.py b/python/x402/http/paywall/evm_paywall_template.py index dd3a49a9cd..8de786c3e7 100644 --- a/python/x402/http/paywall/evm_paywall_template.py +++ b/python/x402/http/paywall/evm_paywall_template.py @@ -1,2 +1,2 @@ # THIS FILE IS AUTO-GENERATED - DO NOT EDIT -EVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' +EVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/python/x402/http/paywall/svm_paywall_template.py b/python/x402/http/paywall/svm_paywall_template.py index 472f9a6355..ca9964aa61 100644 --- a/python/x402/http/paywall/svm_paywall_template.py +++ b/python/x402/http/paywall/svm_paywall_template.py @@ -1,2 +1,2 @@ # THIS FILE IS AUTO-GENERATED - DO NOT EDIT -SVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' +SVM_PAYWALL_TEMPLATE = '\n \n \n Payment Required\n \n
\n \n \n ' diff --git a/python/x402/http/types.py b/python/x402/http/types.py index c9043eb5e4..98350a125b 100644 --- a/python/x402/http/types.py +++ b/python/x402/http/types.py @@ -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) diff --git a/python/x402/tests/unit/http/test_paywall.py b/python/x402/tests/unit/http/test_paywall.py new file mode 100644 index 0000000000..45fc2418ed --- /dev/null +++ b/python/x402/tests/unit/http/test_paywall.py @@ -0,0 +1,141 @@ +"""Tests for paywall handlers and faucet URL plumbing.""" + +from __future__ import annotations + +from x402.http.paywall import ( + EvmPaywallHandler, + PaywallBuilder, + SvmPaywallHandler, +) +from x402.schemas import ( + PaymentRequired, + PaymentRequirements, + ResourceInfo, +) + + +def _make_evm_payment_required() -> PaymentRequired: + return PaymentRequired( + x402_version=2, + accepts=[ + PaymentRequirements( + scheme="exact", + network="eip155:84532", + asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e", + amount="1000000", + pay_to="0x209693Bc6afc0C5328bA36FaF04C514EF312287C", + max_timeout_seconds=60, + ) + ], + resource=ResourceInfo(url="https://example.com/api/data"), + ) + + +def _make_svm_payment_required() -> PaymentRequired: + return PaymentRequired( + x402_version=2, + accepts=[ + PaymentRequirements( + scheme="exact", + network="solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + asset="4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU", + amount="1000000", + pay_to="2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHEBg4", + max_timeout_seconds=60, + ) + ], + resource=ResourceInfo(url="https://example.com/api/data"), + ) + + +# --- EvmPaywallHandler --- + + +def _build_evm_provider(**config_kwargs): # type: ignore[no-untyped-def] + return PaywallBuilder().with_network(EvmPaywallHandler()).with_config(**config_kwargs).build() + + +def _build_svm_provider(**config_kwargs): # type: ignore[no-untyped-def] + return PaywallBuilder().with_network(SvmPaywallHandler()).with_config(**config_kwargs).build() + + +def test_evm_handler_injects_faucet_urls() -> None: + urls = { + "eip155:84532": "https://example.com/base-faucet", + "eip155:421614": "https://example.com/arb-faucet", + } + provider = _build_evm_provider(testnet=True, faucet_urls=urls) + html = provider.generate_html(_make_evm_payment_required()) + assert '"faucetUrls"' in html + assert "https://example.com/base-faucet" in html + assert "https://example.com/arb-faucet" in html + + +def test_evm_handler_omits_faucet_urls_when_unset() -> None: + """When faucet_urls is unset, the injected config script omits the key. + + The paywall renders 'No faucet configured.' when neither the curated map + nor a server override has an entry for the chain. The bundled template + may still mention `faucetUrls` as a property access in compiled JS — that + test only inspects the injected config object. + """ + provider = _build_evm_provider(testnet=True) + html = provider.generate_html(_make_evm_payment_required()) + # Find the config script block and check it doesn't declare faucetUrls. + config_start = html.find("window.x402 = ") + assert config_start != -1 + config_end = html.find(";", config_start) + snippet = html[config_start:config_end] + assert '"faucetUrls"' not in snippet, ( + f"unexpected faucetUrls in injected config: {snippet[:500]}" + ) + + +# --- SvmPaywallHandler --- + + +def test_svm_handler_injects_faucet_urls() -> None: + urls = {"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://example.com/devnet-faucet"} + provider = _build_svm_provider(testnet=True, faucet_urls=urls) + html = provider.generate_html(_make_svm_payment_required()) + assert '"faucetUrls"' in html + assert "https://example.com/devnet-faucet" in html + + +# --- PaywallBuilder --- + + +def test_builder_accepts_faucet_urls() -> None: + urls = {"eip155:84532": "https://example.com/per-chain"} + provider = ( + PaywallBuilder().with_network(EvmPaywallHandler()).with_config(faucet_urls=urls).build() + ) + assert provider.faucet_urls == urls + + +def test_builder_passes_faucet_urls_through_to_handler() -> None: + urls = {"eip155:84532": "https://example.com/per-chain"} + provider = ( + PaywallBuilder().with_network(EvmPaywallHandler()).with_config(faucet_urls=urls).build() + ) + html = provider.generate_html(_make_evm_payment_required()) + assert "https://example.com/per-chain" in html + + +def test_provider_runtime_faucet_urls_override_builder_faucet_urls() -> None: + from x402.http.types import PaywallConfig + + builder_urls = {"eip155:84532": "https://example.com/builder"} + runtime_urls = {"eip155:84532": "https://example.com/runtime"} + provider = ( + PaywallBuilder() + .with_network(EvmPaywallHandler()) + .with_config(faucet_urls=builder_urls) + .build() + ) + html = provider.generate_html( + _make_evm_payment_required(), + config=PaywallConfig(faucet_urls=runtime_urls), + ) + assert "https://example.com/runtime" in html + assert "https://example.com/builder" not in html diff --git a/typescript/.changeset/paywall-faucet-url-registry.md b/typescript/.changeset/paywall-faucet-url-registry.md new file mode 100644 index 0000000000..4d3741a3bd --- /dev/null +++ b/typescript/.changeset/paywall-faucet-url-registry.md @@ -0,0 +1,5 @@ +--- +"@x402/paywall": minor +--- + +Add `faucetUrls?: Record` to `PaywallConfig` plus a curated testnet faucet map in `@x402/paywall`. Server overrides win over the curated map; unmapped chains render "No faucet configured." rather than a fallback link. diff --git a/typescript/packages/http/paywall/src/avm/AvmPaywall.tsx b/typescript/packages/http/paywall/src/avm/AvmPaywall.tsx index 7f21aa4e35..258da50824 100644 --- a/typescript/packages/http/paywall/src/avm/AvmPaywall.tsx +++ b/typescript/packages/http/paywall/src/avm/AvmPaywall.tsx @@ -7,7 +7,8 @@ import { x402Client } from "@x402/core/client"; import type { PaymentRequired } from "@x402/core/types"; import { Spinner } from "./Spinner"; -import { getNetworkDisplayName, ALGORAND_NETWORK_REFS } from "../paywallUtils"; +import { getNetworkDisplayName, isTestnetNetwork } from "../paywallUtils"; +import { resolveFaucetUrl } from "../faucetUrls"; import { getAlgodClient } from "./algorand/rpc"; type AvmPaywallProps = { @@ -78,7 +79,8 @@ export function AvmPaywall({ const network = firstRequirement.network; const chainName = getNetworkDisplayName(network); - const isTestnet = network.includes(ALGORAND_NETWORK_REFS.TESTNET); + const tokenName = (firstRequirement.extra?.name as string) || "USDC"; + const isTestnet = isTestnetNetwork(network); // Get USDC ASA ID based on network const usdcAsaId = firstRequirement.asset @@ -303,20 +305,24 @@ export function AvmPaywall({

Payment Required

{paymentRequired.resource?.description && `${paymentRequired.resource.description}.`} To - access this content, please pay ${amount} {chainName} USDC. + access this content, please pay ${amount} {chainName} {tokenName}.

- {isTestnet && ( -

- Need Algorand Testnet USDC?{" "} - - Request some here. - -

- )} + {isTestnet && + (() => { + const faucetUrl = resolveFaucetUrl(network, x402); + return ( +

+ Need {tokenName} on {chainName}?{" "} + {faucetUrl ? ( + + Request some here. + + ) : ( + No faucet configured. + )} +

+ ); + })()}
diff --git a/typescript/packages/http/paywall/src/avm/gen/template.ts b/typescript/packages/http/paywall/src/avm/gen/template.ts index 279dc13cf0..cd8fa53348 100644 --- a/typescript/packages/http/paywall/src/avm/gen/template.ts +++ b/typescript/packages/http/paywall/src/avm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built AVM paywall template with inlined CSS and JS */ export const AVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n
\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/http/paywall/src/avm/index.ts b/typescript/packages/http/paywall/src/avm/index.ts index 2ecb38bd2d..d0c99de864 100644 --- a/typescript/packages/http/paywall/src/avm/index.ts +++ b/typescript/packages/http/paywall/src/avm/index.ts @@ -46,6 +46,7 @@ export const avmPaywall: PaywallNetworkHandler = { testnet: config.testnet ?? true, appName: config.appName, appLogo: config.appLogo, + faucetUrls: config.faucetUrls, }); }, }; diff --git a/typescript/packages/http/paywall/src/avm/paywall.ts b/typescript/packages/http/paywall/src/avm/paywall.ts index 459cb92fe0..676b70a9ee 100644 --- a/typescript/packages/http/paywall/src/avm/paywall.ts +++ b/typescript/packages/http/paywall/src/avm/paywall.ts @@ -24,6 +24,7 @@ interface AvmPaywallOptions { testnet: boolean; appName?: string; appLogo?: string; + faucetUrls?: Record; } /** @@ -36,6 +37,7 @@ interface AvmPaywallOptions { * @param options.testnet - Whether to use testnet or mainnet * @param options.appName - The name of the application to display in the wallet connection modal * @param options.appLogo - The logo of the application to display in the wallet connection modal + * @param options.faucetUrls - Per-chain (CAIP-2 keyed) override for the testnet faucet link * @returns HTML string for the paywall page */ export function getAvmPaywallHtml(options: AvmPaywallOptions): string { @@ -45,7 +47,7 @@ export function getAvmPaywallHtml(options: AvmPaywallOptions): string { return `

AVM Paywall (run pnpm build:paywall to generate full template)

`; } - const { amount, testnet, paymentRequired, currentUrl, appName, appLogo } = options; + const { amount, testnet, paymentRequired, currentUrl, appName, appLogo, faucetUrls } = options; const logOnTestnet = testnet ? "console.log('AVM Payment required initialized:', window.x402);" @@ -63,6 +65,7 @@ export function getAvmPaywallHtml(options: AvmPaywallOptions): string { }, appName: "${escapeString(appName || "")}", appLogo: "${escapeString(appLogo || "")}", + faucetUrls: ${faucetUrls ? JSON.stringify(faucetUrls) : "undefined"}, }; ${logOnTestnet} `; diff --git a/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx b/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx index 1272de6338..7b2b54e120 100644 --- a/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx +++ b/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx @@ -11,6 +11,7 @@ import { getTokenBalance, getTokenDecimals } from "./utils"; import { Spinner } from "./Spinner"; import { getNetworkDisplayName, isTestnetNetwork } from "../paywallUtils"; +import { resolveFaucetUrl } from "../faucetUrls"; import { wagmiToClientSigner } from "./browserAdapter"; type EvmPaywallProps = { @@ -202,14 +203,22 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall {paymentRequired.resource?.description && `${paymentRequired.resource.description}.`} To access this content, please pay ${amount} {tokenName}.

- {testnet && ( -

- Need {tokenName} on {chainName}?{" "} - - Get some here. - -

- )} + {testnet && + (() => { + const faucetUrl = resolveFaucetUrl(network, x402); + return ( +

+ Need {tokenName} on {chainName}?{" "} + {faucetUrl ? ( + + Request some here. + + ) : ( + No faucet configured. + )} +

+ ); + })()}
diff --git a/typescript/packages/http/paywall/src/evm/gen/template.ts b/typescript/packages/http/paywall/src/evm/gen/template.ts index ba513c4060..c96a87f83d 100644 --- a/typescript/packages/http/paywall/src/evm/gen/template.ts +++ b/typescript/packages/http/paywall/src/evm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built EVM paywall template with inlined CSS and JS */ export const EVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n
\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/http/paywall/src/evm/index.ts b/typescript/packages/http/paywall/src/evm/index.ts index 0b68b6cd85..c2e3f4032a 100644 --- a/typescript/packages/http/paywall/src/evm/index.ts +++ b/typescript/packages/http/paywall/src/evm/index.ts @@ -64,6 +64,7 @@ export const evmPaywall: PaywallNetworkHandler = { testnet: config.testnet ?? true, appName: config.appName, appLogo: config.appLogo, + faucetUrls: config.faucetUrls, }); }, }; diff --git a/typescript/packages/http/paywall/src/evm/paywall.ts b/typescript/packages/http/paywall/src/evm/paywall.ts index c8b66ff66b..561c2fb1d1 100644 --- a/typescript/packages/http/paywall/src/evm/paywall.ts +++ b/typescript/packages/http/paywall/src/evm/paywall.ts @@ -42,6 +42,7 @@ interface EvmPaywallOptions { testnet: boolean; appName?: string; appLogo?: string; + faucetUrls?: Record; } /** @@ -54,6 +55,7 @@ interface EvmPaywallOptions { * @param options.testnet - Whether to use testnet or mainnet * @param options.appName - The name of the application to display in the wallet connection modal * @param options.appLogo - The logo of the application to display in the wallet connection modal + * @param options.faucetUrls - Per-chain (CAIP-2 keyed) override for the testnet faucet link * @returns HTML string for the paywall page */ export function getEvmPaywallHtml(options: EvmPaywallOptions): string { @@ -63,7 +65,7 @@ export function getEvmPaywallHtml(options: EvmPaywallOptions): string { return `

EVM Paywall (run pnpm build:paywall to generate full template)

`; } - const { amount, testnet, paymentRequired, currentUrl, appName, appLogo } = options; + const { amount, testnet, paymentRequired, currentUrl, appName, appLogo, faucetUrls } = options; const logOnTestnet = testnet ? "console.log('EVM Payment required initialized:', window.x402);" @@ -83,6 +85,7 @@ export function getEvmPaywallHtml(options: EvmPaywallOptions): string { }, appName: "${escapeString(appName || "")}", appLogo: "${escapeString(appLogo || "")}", + faucetUrls: ${faucetUrls ? JSON.stringify(faucetUrls) : "undefined"}, }; ${logOnTestnet} `; diff --git a/typescript/packages/http/paywall/src/faucetUrls.ts b/typescript/packages/http/paywall/src/faucetUrls.ts new file mode 100644 index 0000000000..1fd1beeae4 --- /dev/null +++ b/typescript/packages/http/paywall/src/faucetUrls.ts @@ -0,0 +1,37 @@ +/** + * Curated testnet faucet URLs keyed by CAIP-2 network identifier. + * + * Hand-maintained per `DEFAULT_ASSETS.md` "Paywall faucet link (recommended + * for testnets)" section. Mainnet entries omitted (paywall faucet UI is + * testnet-gated). + */ +export const FAUCET_URLS: Record = { + // EVM testnets + "eip155:84532": "https://faucet.circle.com/", // Base Sepolia + "eip155:421614": "https://faucet.circle.com/", // Arbitrum Sepolia + "eip155:31611": "https://faucet.test.mezo.org/", // Mezo Testnet + "eip155:2201": "https://faucet.stable.xyz/faucet", // Stable Testnet + "eip155:72344": "https://testnet.radiustech.xyz/wallet", // Radius Testnet + // SVM testnets + "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1": "https://faucet.circle.com/", + // AVM testnets + "algorand:SGO1GKSzyE7IEPItTxCByw9x8FmnrCDexi9/cOUJOiI=": + "https://dispenser.testnet.aws.algodev.network/", +}; + +/** + * Resolve the testnet faucet URL for a network. Returns undefined when no URL + * is configured (caller renders fallback text). Server override + * (`PaywallConfig.faucetUrls`) wins over the curated map. + * + * @param network - CAIP-2 network identifier + * @param x402 - Object exposing the optional consumer `faucetUrls` override + * @param x402.faucetUrls - Optional per-chain override map keyed by CAIP-2 identifier + * @returns Resolved faucet URL or undefined when no entry exists + */ +export function resolveFaucetUrl( + network: string, + x402: { faucetUrls?: Record }, +): string | undefined { + return x402.faucetUrls?.[network] ?? FAUCET_URLS[network]; +} diff --git a/typescript/packages/http/paywall/src/network-handlers.test.ts b/typescript/packages/http/paywall/src/network-handlers.test.ts index cf66143f19..9cd827d997 100644 --- a/typescript/packages/http/paywall/src/network-handlers.test.ts +++ b/typescript/packages/http/paywall/src/network-handlers.test.ts @@ -3,6 +3,8 @@ import { DEFAULT_STABLECOINS } from "@x402/evm"; import { evmPaywall, getDefaultTokenDecimals } from "./evm"; import { NETWORK_DECIMALS } from "./evm/gen/decimals"; import { svmPaywall } from "./svm"; +import { FAUCET_URLS, resolveFaucetUrl } from "./faucetUrls"; +import { isTestnetNetwork, SOLANA_NETWORK_REFS } from "./paywallUtils"; import type { PaymentRequired, PaymentRequirements } from "./types"; const evmRequirement: PaymentRequirements = { @@ -178,6 +180,40 @@ describe("Network Handlers", () => { }); }); + describe("resolveFaucetUrl", () => { + it("returns the curated map URL for a known testnet", () => { + expect(resolveFaucetUrl("eip155:84532", {})).toBe(FAUCET_URLS["eip155:84532"]); + }); + + it("returns undefined for unmapped chains so the paywall renders fallback text", () => { + expect(resolveFaucetUrl("eip155:9999999", {})).toBeUndefined(); + }); + + it("server faucetUrls override wins over the curated map", () => { + const url = resolveFaucetUrl("eip155:84532", { + faucetUrls: { "eip155:84532": "https://custom.example/faucet" }, + }); + expect(url).toBe("https://custom.example/faucet"); + }); + + it("falls back to the curated map when faucetUrls has no entry for the chain", () => { + const url = resolveFaucetUrl("eip155:84532", { + faucetUrls: { "eip155:421614": "https://other-chain.example/faucet" }, + }); + expect(url).toBe(FAUCET_URLS["eip155:84532"]); + }); + }); + + describe("isTestnetNetwork", () => { + it("recognizes Solana Devnet as a testnet", () => { + expect(isTestnetNetwork(`solana:${SOLANA_NETWORK_REFS.DEVNET}`)).toBe(true); + }); + + it("rejects Solana Mainnet", () => { + expect(isTestnetNetwork(`solana:${SOLANA_NETWORK_REFS.MAINNET}`)).toBe(false); + }); + }); + describe("svmPaywall", () => { it("supports CAIP-2 Solana networks", () => { expect( diff --git a/typescript/packages/http/paywall/src/svm/SolanaPaywall.tsx b/typescript/packages/http/paywall/src/svm/SolanaPaywall.tsx index 74fa2f4409..c7c315d1cd 100644 --- a/typescript/packages/http/paywall/src/svm/SolanaPaywall.tsx +++ b/typescript/packages/http/paywall/src/svm/SolanaPaywall.tsx @@ -8,7 +8,8 @@ import { encodePaymentSignatureHeader } from "@x402/core/http"; import type { PaymentRequired } from "@x402/core/types"; import { Spinner } from "./Spinner"; -import { getNetworkDisplayName, SOLANA_NETWORK_REFS } from "../paywallUtils"; +import { getNetworkDisplayName, isTestnetNetwork, SOLANA_NETWORK_REFS } from "../paywallUtils"; +import { resolveFaucetUrl } from "../faucetUrls"; import { getStandardConnectFeature, getStandardDisconnectFeature } from "./solana/features"; import { useSolanaBalance } from "./solana/useSolanaBalance"; import { useSolanaSigner } from "./solana/useSolanaSigner"; @@ -50,6 +51,8 @@ export function SolanaPaywall({ paymentRequired, onSuccessfulResponse }: SolanaP const network = firstRequirement.network; const chainName = getNetworkDisplayName(network); + const tokenName = (firstRequirement.extra?.name as string) || "USDC"; + const testnet = isTestnetNetwork(network); const isMainnet = network.includes(SOLANA_NETWORK_REFS.MAINNET); const targetChain = isMainnet ? ("solana:mainnet" as const) : ("solana:devnet" as const); @@ -223,16 +226,24 @@ export function SolanaPaywall({ paymentRequired, onSuccessfulResponse }: SolanaP

Payment Required

{paymentRequired.resource?.description && `${paymentRequired.resource.description}.`} To - access this content, please pay ${amount} {chainName} USDC. + access this content, please pay ${amount} {chainName} {tokenName}.

- {String(network).includes("devnet") && ( -

- Need Solana Devnet USDC?{" "} - - Request some here. - -

- )} + {testnet && + (() => { + const faucetUrl = resolveFaucetUrl(network, x402); + return ( +

+ Need {tokenName} on {chainName}?{" "} + {faucetUrl ? ( + + Request some here. + + ) : ( + No faucet configured. + )} +

+ ); + })()}
diff --git a/typescript/packages/http/paywall/src/svm/gen/template.ts b/typescript/packages/http/paywall/src/svm/gen/template.ts index b6a1322e2a..7a8c0d8844 100644 --- a/typescript/packages/http/paywall/src/svm/gen/template.ts +++ b/typescript/packages/http/paywall/src/svm/gen/template.ts @@ -3,4 +3,4 @@ * The pre-built SVM paywall template with inlined CSS and JS */ export const SVM_PAYWALL_TEMPLATE = - '\n \n \n Payment Required\n \n
\n \n \n '; + '\n \n \n Payment Required\n \n
\n \n \n '; diff --git a/typescript/packages/http/paywall/src/svm/index.ts b/typescript/packages/http/paywall/src/svm/index.ts index 3b147188ee..fa1e86834a 100644 --- a/typescript/packages/http/paywall/src/svm/index.ts +++ b/typescript/packages/http/paywall/src/svm/index.ts @@ -46,6 +46,7 @@ export const svmPaywall: PaywallNetworkHandler = { testnet: config.testnet ?? true, appName: config.appName, appLogo: config.appLogo, + faucetUrls: config.faucetUrls, }); }, }; diff --git a/typescript/packages/http/paywall/src/svm/paywall.ts b/typescript/packages/http/paywall/src/svm/paywall.ts index bb2863cdcd..021d028187 100644 --- a/typescript/packages/http/paywall/src/svm/paywall.ts +++ b/typescript/packages/http/paywall/src/svm/paywall.ts @@ -24,6 +24,7 @@ interface SvmPaywallOptions { testnet: boolean; appName?: string; appLogo?: string; + faucetUrls?: Record; } /** @@ -36,6 +37,7 @@ interface SvmPaywallOptions { * @param options.testnet - Whether to use testnet or mainnet * @param options.appName - The name of the application to display in the wallet connection modal * @param options.appLogo - The logo of the application to display in the wallet connection modal + * @param options.faucetUrls - Per-chain (CAIP-2 keyed) override for the testnet faucet link * @returns HTML string for the paywall page */ export function getSvmPaywallHtml(options: SvmPaywallOptions): string { @@ -45,7 +47,7 @@ export function getSvmPaywallHtml(options: SvmPaywallOptions): string { return `

SVM Paywall (run pnpm build:paywall to generate full template)

`; } - const { amount, testnet, paymentRequired, currentUrl, appName, appLogo } = options; + const { amount, testnet, paymentRequired, currentUrl, appName, appLogo, faucetUrls } = options; const logOnTestnet = testnet ? "console.log('SVM Payment required initialized:', window.x402);" @@ -63,6 +65,7 @@ export function getSvmPaywallHtml(options: SvmPaywallOptions): string { }, appName: "${escapeString(appName || "")}", appLogo: "${escapeString(appLogo || "")}", + faucetUrls: ${faucetUrls ? JSON.stringify(faucetUrls) : "undefined"}, }; ${logOnTestnet} `; diff --git a/typescript/packages/http/paywall/src/types.ts b/typescript/packages/http/paywall/src/types.ts index 1ac791ab61..1e1315569a 100644 --- a/typescript/packages/http/paywall/src/types.ts +++ b/typescript/packages/http/paywall/src/types.ts @@ -6,6 +6,15 @@ export interface PaywallConfig { appLogo?: string; currentUrl?: string; testnet?: boolean; + /** + * Per-chain override for the testnet faucet link, keyed by CAIP-2 network + * identifier (e.g. `"eip155:84532"`, `"solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1"`). + * + * Wins over the paywall's curated `FAUCET_URLS` map for the matching chain. + * When neither this override nor the curated map has an entry, the paywall + * renders "No faucet configured." instead of a fallback link. + */ + faucetUrls?: Record; } /** diff --git a/typescript/packages/http/paywall/src/window.d.ts b/typescript/packages/http/paywall/src/window.d.ts index ded162e4be..0ff0024637 100644 --- a/typescript/packages/http/paywall/src/window.d.ts +++ b/typescript/packages/http/paywall/src/window.d.ts @@ -9,6 +9,7 @@ declare global { currentUrl: string; appName?: string; appLogo?: string; + faucetUrls?: Record; config: { chainConfig: Record< string,