diff --git a/go/http/evm_paywall_template.go b/go/http/evm_paywall_template.go index 83867a322e..ee048fd03e 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/python/x402/http/paywall/evm_paywall_template.py b/python/x402/http/paywall/evm_paywall_template.py index d39b605ad5..071a14b934 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/typescript/packages/http/paywall/src/builder.test.ts b/typescript/packages/http/paywall/src/builder.test.ts index a365b3b19d..02abd33d3e 100644 --- a/typescript/packages/http/paywall/src/builder.test.ts +++ b/typescript/packages/http/paywall/src/builder.test.ts @@ -131,6 +131,27 @@ describe("PaywallBuilder", () => { expect(html).toContain("0.1"); }); + it("uses the payment token decimals for non-6-decimal EVM assets", () => { + const paywall = createPaywall().withNetwork(evmPaywall).build(); + const megaUsdPaymentRequired: PaymentRequired = { + ...mockPaymentRequired, + accepts: [ + { + scheme: "exact", + network: "eip155:4326", + asset: "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7", + amount: "1000000000000000", + payTo: "0x209693Bc6afc0C5328bA36FaF04C514EF312287C", + maxTimeoutSeconds: 60, + }, + ], + }; + + const html = paywall.generateHtml(megaUsdPaymentRequired); + + expect(html).toContain("amount: 0.001"); + }); + it("uses resource URL as currentUrl when not provided", () => { const paywall = createPaywall().withNetwork(evmPaywall).build(); const html = paywall.generateHtml(mockPaymentRequired); diff --git a/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx b/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx index 81dc062566..3b38214e35 100644 --- a/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx +++ b/typescript/packages/http/paywall/src/evm/EvmPaywall.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { createPublicClient, formatUnits, http, publicActions, type Chain } from "viem"; +import { + createPublicClient, + formatUnits, + http, + publicActions, + type Address, + type Chain, +} from "viem"; import * as allChains from "viem/chains"; import { useAccount, useSwitchChain, useWalletClient, useConnect, useDisconnect } from "wagmi"; @@ -7,7 +14,7 @@ import { ExactEvmScheme } from "@x402/evm/exact/client"; import { x402Client } from "@x402/core/client"; import { encodePaymentSignatureHeader } from "@x402/core/http"; import type { PaymentRequired } from "@x402/core/types"; -import { getUSDCBalance } from "./utils"; +import { getTokenBalance, getTokenDecimals } from "./utils"; import { Spinner } from "./Spinner"; import { getNetworkDisplayName, isTestnetNetwork } from "../paywallUtils"; @@ -36,7 +43,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall const [status, setStatus] = useState(""); const [isCorrectChain, setIsCorrectChain] = useState(null); const [isPaying, setIsPaying] = useState(false); - const [formattedUsdcBalance, setFormattedUsdcBalance] = useState(""); + const [formattedTokenBalance, setFormattedTokenBalance] = useState(""); const [hideBalance, setHideBalance] = useState(true); const [selectedConnectorId, setSelectedConnectorId] = useState(""); @@ -50,6 +57,9 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall const network = firstRequirement.network; const tokenName = (firstRequirement.extra?.name as string) || "Token"; + const paymentTokenAddress = firstRequirement.asset as Address; + const fallbackTokenDecimals = + typeof firstRequirement.extra?.decimals === "number" ? firstRequirement.extra.decimals : 6; const chainName = getNetworkDisplayName(network); const testnet = isTestnetNetwork(network); @@ -71,14 +81,17 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall [paymentChain], ); - const checkUSDCBalance = useCallback(async () => { + const checkTokenBalance = useCallback(async () => { if (!address) { return; } - const balance = await getUSDCBalance(publicClient, address); - const formattedBalance = formatUnits(balance, 6); - setFormattedUsdcBalance(formattedBalance); - }, [address, publicClient]); + const [balance, decimals] = await Promise.all([ + getTokenBalance(publicClient, paymentTokenAddress, address), + getTokenDecimals(publicClient, paymentTokenAddress, fallbackTokenDecimals), + ]); + const formattedBalance = formatUnits(balance, decimals); + setFormattedTokenBalance(formattedBalance); + }, [address, fallbackTokenDecimals, paymentTokenAddress, publicClient]); const handleSwitchChain = useCallback(async () => { if (isCorrectChain) { @@ -100,8 +113,8 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall } void handleSwitchChain(); - void checkUSDCBalance(); - }, [address, handleSwitchChain, checkUSDCBalance]); + void checkTokenBalance(); + }, [address, handleSwitchChain, checkTokenBalance]); useEffect(() => { if (isConnected && chainId === connectedChainId) { @@ -140,7 +153,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall try { setStatus("Checking balance..."); - const balance = await getUSDCBalance(publicClient, address); + const balance = await getTokenBalance(publicClient, paymentTokenAddress, address); if (balance === 0n) { throw new Error(`Insufficient balance. Make sure you have ${tokenName} on ${chainName}`); @@ -183,6 +196,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall handleSwitchChain, wagmiWalletClient, publicClient, + paymentTokenAddress, chainName, onSuccessfulResponse, ]); @@ -259,8 +273,8 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall Available balance: diff --git a/typescript/packages/http/paywall/src/evm/gen/template.ts b/typescript/packages/http/paywall/src/evm/gen/template.ts index 73ce58d52c..a43aba69de 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 88e24ebcc2..5a7459d1b4 100644 --- a/typescript/packages/http/paywall/src/evm/index.ts +++ b/typescript/packages/http/paywall/src/evm/index.ts @@ -1,3 +1,5 @@ +import type { Network } from "@x402/core/types"; +import { formatUnits } from "viem"; import type { PaywallNetworkHandler, PaymentRequirements, @@ -5,6 +7,32 @@ import type { PaywallConfig, } from "../types"; import { getEvmPaywallHtml } from "./paywall"; +import { getDefaultAsset } from "../../../../mechanisms/evm/src/shared/defaultAssets"; + +function getRequirementDecimals(requirement: PaymentRequirements): number { + const decimals = requirement.extra?.decimals; + if (typeof decimals === "number") { + return decimals; + } + + try { + return getDefaultAsset(requirement.network as Network).decimals; + } catch { + return 6; + } +} + +function getDisplayAmount(rawAmount: string | undefined, decimals: number): number { + if (!rawAmount) { + return 0; + } + + try { + return Number(formatUnits(BigInt(rawAmount), decimals)); + } catch { + return parseFloat(rawAmount) / 10 ** decimals; + } +} /** * EVM paywall handler that supports EVM-based networks (CAIP-2 format only) @@ -33,11 +61,8 @@ export const evmPaywall: PaywallNetworkHandler = { paymentRequired: PaymentRequired, config: PaywallConfig, ): string { - const amount = requirement.amount - ? parseFloat(requirement.amount) / 1000000 - : requirement.maxAmountRequired - ? parseFloat(requirement.maxAmountRequired) / 1000000 - : 0; + const decimals = getRequirementDecimals(requirement); + const amount = getDisplayAmount(requirement.amount ?? requirement.maxAmountRequired, decimals); return getEvmPaywallHtml({ amount, diff --git a/typescript/packages/http/paywall/src/evm/utils.test.ts b/typescript/packages/http/paywall/src/evm/utils.test.ts new file mode 100644 index 0000000000..da5bfa9589 --- /dev/null +++ b/typescript/packages/http/paywall/src/evm/utils.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Address, Chain, Client, Transport } from "viem"; +import { getTokenBalance, getTokenDecimals } from "./utils"; + +function createMockClient(readContract: ReturnType) { + return { + chain: { id: 4326 } as Chain, + readContract, + } as unknown as Client; +} + +describe("evm token utils", () => { + it("reads token balances from the requested asset address", async () => { + const readContract = vi.fn().mockResolvedValue(1234567890123456789n); + const client = createMockClient(readContract); + const tokenAddress = "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7" as Address; + const holder = "0x209693Bc6afc0C5328bA36FaF04C514EF312287C" as Address; + + const balance = await getTokenBalance(client, tokenAddress, holder); + + expect(balance).toBe(1234567890123456789n); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: tokenAddress, + functionName: "balanceOf", + args: [holder], + }), + ); + }); + + it("reads token decimals from the requested asset address", async () => { + const readContract = vi.fn().mockResolvedValue(18); + const client = createMockClient(readContract); + const tokenAddress = "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7" as Address; + + const decimals = await getTokenDecimals(client, tokenAddress); + + expect(decimals).toBe(18); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: tokenAddress, + functionName: "decimals", + }), + ); + }); + + it("falls back to a provided decimal value when token metadata lookup fails", async () => { + const readContract = vi.fn().mockRejectedValue(new Error("rpc unavailable")); + const client = createMockClient(readContract); + const tokenAddress = "0xFAfDdbb3FC7688494971a79cc65DCa3EF82079E7" as Address; + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + try { + await expect(getTokenDecimals(client, tokenAddress, 18)).resolves.toBe(18); + expect(consoleErrorSpy).toHaveBeenCalled(); + } finally { + consoleErrorSpy.mockRestore(); + } + }); +}); diff --git a/typescript/packages/http/paywall/src/evm/utils.ts b/typescript/packages/http/paywall/src/evm/utils.ts index 1a04f67629..c2c9d64ab7 100644 --- a/typescript/packages/http/paywall/src/evm/utils.ts +++ b/typescript/packages/http/paywall/src/evm/utils.ts @@ -1,18 +1,9 @@ import type { Address, Client, Chain, Transport, Account } from "viem"; /** - * USDC contract addresses by chain ID + * ERC20 token ABI fragments used by the EVM paywall. */ -const USDC_ADDRESSES: Record = { - 1: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum Mainnet - 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base Mainnet - 84532: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia -}; - -/** - * ERC20 balanceOf ABI - */ -const ERC20_BALANCE_ABI = [ +const ERC20_ABI = [ { name: "balanceOf", type: "function", @@ -20,36 +11,73 @@ const ERC20_BALANCE_ABI = [ inputs: [{ name: "account", type: "address" }], outputs: [{ name: "balance", type: "uint256" }], }, + { + name: "decimals", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "decimals", type: "uint8" }], + }, ] as const; /** - * Gets the USDC balance for a specific address on the current chain. + * Gets an ERC-20 token balance for a specific address on the current chain. * * @param client - Viem client instance connected to the blockchain - * @param address - Address to check the USDC balance for - * @returns USDC balance as bigint (0 if USDC not supported on chain or error) + * @param tokenAddress - Token contract address + * @param address - Address to check the token balance for + * @returns Token balance as bigint (0 if the lookup fails) */ -export async function getUSDCBalance< +export async function getTokenBalance< TTransport extends Transport, TChain extends Chain, TAccount extends Account | undefined = undefined, ->(client: Client, address: Address): Promise { - const chainId = client.chain?.id; - if (!chainId) return 0n; - - const usdcAddress = USDC_ADDRESSES[chainId]; - if (!usdcAddress) return 0n; - +>( + client: Client, + tokenAddress: Address, + address: Address, +): Promise { try { const balance = await client.readContract({ - address: usdcAddress, - abi: ERC20_BALANCE_ABI, + address: tokenAddress, + abi: ERC20_ABI, functionName: "balanceOf", args: [address], }); return balance as bigint; } catch (error) { - console.error("Failed to fetch USDC balance:", error); + console.error("Failed to fetch token balance:", error); return 0n; } } + +/** + * Gets the decimal precision for an ERC-20 token. + * + * @param client - Viem client instance connected to the blockchain + * @param tokenAddress - Token contract address + * @param fallbackDecimals - Decimal precision to use if the lookup fails + * @returns Token decimals, or the fallback value when unavailable + */ +export async function getTokenDecimals< + TTransport extends Transport, + TChain extends Chain, + TAccount extends Account | undefined = undefined, +>( + client: Client, + tokenAddress: Address, + fallbackDecimals: number = 6, +): Promise { + try { + const decimals = await client.readContract({ + address: tokenAddress, + abi: ERC20_ABI, + functionName: "decimals", + args: [], + }); + return Number(decimals); + } catch (error) { + console.error("Failed to fetch token decimals:", error); + return fallbackDecimals; + } +}