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;
+ }
+}