Skip to content
Closed
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
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 python/x402/http/paywall/evm_paywall_template.py

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions typescript/packages/http/paywall/src/builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
40 changes: 27 additions & 13 deletions typescript/packages/http/paywall/src/evm/EvmPaywall.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
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";

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 +43,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 [formattedTokenBalance, setFormattedTokenBalance] = useState<string>("");
const [hideBalance, setHideBalance] = useState(true);
const [selectedConnectorId, setSelectedConnectorId] = useState<string>("");

Expand All @@ -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);

Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -183,6 +196,7 @@ export function EvmPaywall({ paymentRequired, onSuccessfulResponse }: EvmPaywall
handleSwitchChain,
wagmiWalletClient,
publicClient,
paymentTokenAddress,
chainName,
onSuccessfulResponse,
]);
Expand Down Expand Up @@ -259,8 +273,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}`
{formattedTokenBalance && !hideBalance
? `$${formattedTokenBalance} ${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.

35 changes: 30 additions & 5 deletions typescript/packages/http/paywall/src/evm/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import type { Network } from "@x402/core/types";
import { formatUnits } from "viem";
import type {
PaywallNetworkHandler,
PaymentRequirements,
PaymentRequired,
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)
Expand Down Expand Up @@ -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,
Expand Down
60 changes: 60 additions & 0 deletions typescript/packages/http/paywall/src/evm/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>) {
return {
chain: { id: 4326 } as Chain,
readContract,
} as unknown as Client<Transport, Chain, undefined>;
}

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();
}
});
});
78 changes: 53 additions & 25 deletions typescript/packages/http/paywall/src/evm/utils.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,83 @@
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<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 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<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>,
tokenAddress: Address,
address: Address,
): Promise<bigint> {
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<TTransport, TChain, TAccount>,
tokenAddress: Address,
fallbackDecimals: number = 6,
): Promise<number> {
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;
}
}
Loading