Skip to content
Open
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
617 changes: 533 additions & 84 deletions examples/typescript/pnpm-lock.yaml

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.

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

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.

22 changes: 22 additions & 0 deletions typescript/.changeset/fix-paywall-dynamic-decimals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@x402/paywall": minor
"@x402/evm": minor
---

fix(paywall): use dynamic token decimals instead of hardcoding 6

The EVM paywall no longer assumes all tokens have 6 decimal places. Server-side
amount conversion in `evmPaywall.generateHtml`:

- Resolves the token's decimal precision via a new `getDefaultTokenDecimals`
helper that looks up the network in `@x402/evm`'s `DEFAULT_STABLECOINS`
registry — the same source the scheme `getAssetDecimals` methods read from
and the inline scheme dispatch in `@x402/core`'s `x402ResourceServer` uses.
Falls back to 6 (USDC default) when the network is unknown.
- Replaces the lossy `parseFloat(amount) / 10**decimals` math with
`Number(formatUnits(BigInt(amount), decimals))`, preserving precision
through the atomic-to-display conversion.

`@x402/evm` now publicly re-exports `DEFAULT_STABLECOINS` from
`./shared/defaultAssets` so consumers can read the canonical default-asset
registry directly.
2 changes: 1 addition & 1 deletion typescript/packages/http/paywall/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
"@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.29.1",
"@x402/avm": "workspace:~",
"@x402/evm": "workspace:~",
"@x402/svm": "workspace:~",
"buffer": "^6.0.3",
"esbuild": "^0.25.4",
Expand Down Expand Up @@ -75,6 +74,7 @@
"@wallet-standard/base": "^1.1.0",
"@wallet-standard/features": "^1.1.0",
"@x402/core": "workspace:~",
"@x402/evm": "workspace:~",
"viem": "^2.39.3",
"wagmi": "^2.17.1",
"zod": "^3.24.2"
Expand Down
27 changes: 15 additions & 12 deletions typescript/packages/http/paywall/src/evm/EvmPaywall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,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";
Expand Down Expand Up @@ -36,7 +36,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall
const [status, setStatus] = useState<string>("");
const [isCorrectChain, setIsCorrectChain] = useState<boolean | null>(null);
const [isPaying, setIsPaying] = useState(false);
const [formattedUsdcBalance, setFormattedUsdcBalance] = useState<string>("");
const [formattedBalance, setFormattedBalance] = useState<string>("");
const [hideBalance, setHideBalance] = useState(true);
const [selectedConnectorId, setSelectedConnectorId] = useState<string>("");

Expand All @@ -50,6 +50,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall

const network = firstRequirement.network;
const tokenName = (firstRequirement.extra?.name as string) || "Token";
const tokenAddress = firstRequirement.asset as `0x${string}`;
const chainName = getNetworkDisplayName(network);
const testnet = isTestnetNetwork(network);

Expand All @@ -71,14 +72,16 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall
[paymentChain],
);

const checkUSDCBalance = useCallback(async () => {
const checkBalance = 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, address, tokenAddress),
getTokenDecimals(publicClient, tokenAddress),
]);
setFormattedBalance(formatUnits(balance, decimals));
}, [address, publicClient, tokenAddress]);

const handleSwitchChain = useCallback(async () => {
if (isCorrectChain) {
Expand All @@ -100,8 +103,8 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall
}

void handleSwitchChain();
void checkUSDCBalance();
}, [address, handleSwitchChain, checkUSDCBalance]);
void checkBalance();
}, [address, handleSwitchChain, checkBalance]);

useEffect(() => {
if (isConnected && chainId === connectedChainId) {
Expand Down Expand Up @@ -140,7 +143,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall

try {
setStatus("Checking balance...");
const balance = await getUSDCBalance(publicClient, address);
const balance = await getTokenBalance(publicClient, address, tokenAddress);

if (balance === 0n) {
throw new Error(`Insufficient balance. Make sure you have ${tokenName} on ${chainName}`);
Expand Down Expand Up @@ -259,8 +262,8 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall
<span className="payment-label">Available balance:</span>
<span className="payment-value">
<button className="balance-button" onClick={() => setHideBalance(prev => !prev)}>
{formattedUsdcBalance && !hideBalance
? `$${formattedUsdcBalance} ${tokenName}`
{formattedBalance && !hideBalance
? `$${formattedBalance} ${tokenName}`
: `••••• ${tokenName}`}
</button>
</span>
Expand Down
2 changes: 1 addition & 1 deletion typescript/packages/http/paywall/src/evm/gen/template.ts

Large diffs are not rendered by default.

26 changes: 21 additions & 5 deletions typescript/packages/http/paywall/src/evm/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { formatUnits } from "viem";
import { DEFAULT_STABLECOINS } from "@x402/evm";
import type {
PaywallNetworkHandler,
PaymentRequirements,
Expand All @@ -6,6 +8,20 @@ import type {
} from "../types";
import { getEvmPaywallHtml } from "./paywall";

/**
* Resolves the token decimals for a payment requirement by looking up the
* network in `@x402/evm`'s `DEFAULT_STABLECOINS` registry — the same source
* the scheme `getAssetDecimals` methods read from and the inline scheme
* dispatch in `@x402/core`'s `x402ResourceServer` uses. Falls back to 6
* (USDC default) when the network is unknown.
*
* @param requirement - The payment requirement
* @returns The number of decimals for the payment token
*/
export function getDefaultTokenDecimals(requirement: PaymentRequirements): number {
return DEFAULT_STABLECOINS[requirement.network]?.decimals ?? 6;
}

/**
* EVM paywall handler that supports EVM-based networks (CAIP-2 format only)
*/
Expand Down Expand Up @@ -33,11 +49,11 @@ 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 = getDefaultTokenDecimals(requirement);
const atomic = requirement.amount ?? requirement.maxAmountRequired;
// BigInt + formatUnits preserves precision through the conversion;
// parseFloat collapses sub-cent digits on real 18-decimal amounts.
const amount = atomic ? Number(formatUnits(BigInt(atomic), decimals)) : 0;

return getEvmPaywallHtml({
amount,
Expand Down
75 changes: 49 additions & 26 deletions typescript/packages/http/paywall/src/evm/utils.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,78 @@
import type { Address, Client, Chain, Transport, Account } from "viem";

/**
* USDC contract addresses by chain ID
* ERC20 ABI for balance and decimal queries
*/
const USDC_ADDRESSES: Record<number, Address> = {
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",
stateMutability: "view",
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 the token balance for a specific address.
*
* @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 owner - Address to check the balance for
* @param tokenAddress - ERC-20 token contract address
* @returns Token balance as bigint (0 on error)
*/
export async function getUSDCBalance<
export async function getTokenBalance<
TTransport extends Transport,
TChain extends Chain,
TAccount extends Account | undefined = undefined,
>(client: Client<TTransport, TChain, TAccount>, address: Address): Promise<bigint> {
const chainId = client.chain?.id;
if (!chainId) return 0n;

const usdcAddress = USDC_ADDRESSES[chainId];
if (!usdcAddress) return 0n;

>(
client: Client<TTransport, TChain, TAccount>,
owner: Address,
tokenAddress: Address,
): Promise<bigint> {
try {
const balance = await client.readContract({
address: usdcAddress,
abi: ERC20_BALANCE_ABI,
address: tokenAddress,
abi: ERC20_ABI,
functionName: "balanceOf",
args: [address],
args: [owner],
});
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 token decimals from the on-chain contract.
* Falls back to 6 (USDC default) if the query fails.
*
* @param client - Viem client instance connected to the blockchain
* @param tokenAddress - ERC-20 token contract address
* @returns Token decimals (defaults to 6 on error)
*/
export async function getTokenDecimals<
TTransport extends Transport,
TChain extends Chain,
TAccount extends Account | undefined = undefined,
>(client: Client<TTransport, TChain, TAccount>, tokenAddress: Address): Promise<number> {
try {
const decimals = await client.readContract({
address: tokenAddress,
abi: ERC20_ABI,
functionName: "decimals",
});
return Number(decimals);
} catch (error) {
console.error("Failed to fetch token decimals:", error);
return 6;
}
}
97 changes: 96 additions & 1 deletion typescript/packages/http/paywall/src/network-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { evmPaywall } from "./evm";
import { DEFAULT_STABLECOINS } from "@x402/evm";
import { evmPaywall, getDefaultTokenDecimals } from "./evm";
import { svmPaywall } from "./svm";
import type { PaymentRequired, PaymentRequirements } from "./types";

Expand Down Expand Up @@ -55,6 +56,100 @@ describe("Network Handlers", () => {
expect(html).toContain("<!DOCTYPE html>");
expect(html).toMatch(/Test App|EVM Paywall/);
});

it("renders 1e15-atomic Mezo mUSD as 0.001 (18-decimal end-to-end)", () => {
// Mezo Testnet mUSD is 18-decimal in DEFAULT_STABLECOINS.
// 1e15 atomic = 0.001 mUSD. A regression to the old `parseFloat / 1e6`
// path would render this as 1_000_000_000 (the order-of-magnitude bug
// this PR fixes).
const req: PaymentRequirements = {
scheme: "exact",
network: "eip155:31611",
asset: "0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503",
amount: "1000000000000000",
payTo: "0x209693Bc6afc0C5328bA36FaF04C514EF312287C",
maxTimeoutSeconds: 60,
};
const html = evmPaywall.generateHtml(
req,
{ ...mockPaymentRequired, accepts: [req] },
{ appName: "Mezo Test", testnet: true },
);
expect(html).toContain("amount: 0.001,");
expect(html).not.toMatch(/amount: 1000000000(?!\.)/);
});

it("rejects non-integer atomic amount strings (BigInt strictness)", () => {
// The spec defines `amount` as an atomic integer string. The previous
// parseFloat-based implementation silently coerced non-integer inputs
// (e.g. "1.5", "1e15"); the BigInt-based implementation throws.
// This test pins that strictness so a future revert to parseFloat fails.
const req: PaymentRequirements = {
...evmRequirement,
network: "eip155:8453",
amount: "1.5",
};
expect(() =>
evmPaywall.generateHtml(
req,
{ ...mockPaymentRequired, accepts: [req] },
{
appName: "Strictness Test",
testnet: true,
},
),
).toThrow();
});

it("renders 1e6-atomic Base USDC as 1 (6-decimal end-to-end)", () => {
// Base mainnet USDC is 6-decimal in DEFAULT_STABLECOINS.
// 1e6 atomic = 1.00 USDC. Asserts the same dispatch behaves correctly
// for the canonical 6-decimal case alongside the 18-decimal case above.
const req: PaymentRequirements = {
scheme: "exact",
network: "eip155:8453",
asset: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
amount: "1000000",
payTo: "0x209693Bc6afc0C5328bA36FaF04C514EF312287C",
maxTimeoutSeconds: 60,
};
const html = evmPaywall.generateHtml(
req,
{ ...mockPaymentRequired, accepts: [req] },
{ appName: "Base Test", testnet: false },
);
expect(html).toContain("amount: 1,");
});
});

describe("getDefaultTokenDecimals", () => {
it("reads non-default decimals from the @x402/evm registry", () => {
// Mezo Testnet mUSD is 18-decimal in DEFAULT_STABLECOINS
const req: PaymentRequirements = {
...evmRequirement,
network: "eip155:31611",
asset: "0x118917a40FAF1CD7a13dB0Ef56C86De7973Ac503",
};
expect(getDefaultTokenDecimals(req)).toBe(18);
});

it("reads the registry value (not the fallback) for a known 6-decimal chain", () => {
// Base mainnet USDC is in the registry at 6 decimals. Asserting
// alongside DEFAULT_STABLECOINS catches the case where the registry is
// empty: the function would still return 6 via fallback, but the second
// assertion would fail.
const req: PaymentRequirements = { ...evmRequirement, network: "eip155:8453" };
expect(getDefaultTokenDecimals(req)).toBe(6);
expect(DEFAULT_STABLECOINS["eip155:8453"]?.decimals).toBe(6);
});

it("falls back to 6 (USDC default) for networks not in the registry", () => {
const req: PaymentRequirements = {
...evmRequirement,
network: "eip155:9999999", // unknown network
};
expect(getDefaultTokenDecimals(req)).toBe(6);
});
});

describe("svmPaywall", () => {
Expand Down
2 changes: 1 addition & 1 deletion typescript/packages/http/paywall/src/svm/gen/template.ts

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions typescript/packages/mechanisms/evm/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ export {
x402ExactPermit2ProxyABI,
x402UptoPermit2ProxyABI,
} from "./constants";

// Default-asset registry (network → token metadata)
export { DEFAULT_STABLECOINS } from "./shared/defaultAssets";
Loading
Loading