Skip to main content
This example demonstrates how to make x402 payments on Base Sepolia using a Ledger hardware wallet. Ledger provides the highest security by keeping your private key on a physical device, and it supports EIP-712 signing required for EIP-3009 gasless payments. Source: GitHub › scripts/evm-example/ledger-base-sepolia-payment.ts

When to Use Ledger for EVM

Use Ledger when:
  • You need maximum security for your keys
  • You’re handling valuable assets
  • You want physical confirmation of EIP-712 signatures
  • You’re building applications that prioritize security
Use software wallets when:
  • You need programmatic or automated transactions
  • You’re building automated systems
  • You’re testing or developing quickly

Full Example

import {
  createLedgerEvmWallet,
  selectLedgerAccount,
  createReadlineInterface,
} from "@faremeter/wallet-ledger";
import { createPaymentHandler } from "@faremeter/payment-evm/exact";
import { wrap as wrapFetch } from "@faremeter/fetch";
import type { TypedDataDefinition } from "viem";
import { baseSepolia } from "viem/chains";

// Parse command line arguments
const args = process.argv.slice(2);
const port = args[0] ?? "4021";
const endpoint = args[1] ?? "weather";
const url = `http://localhost:${port}/${endpoint}`;

const ui = await createReadlineInterface(process);

ui.message("Connecting to Ledger for Base Sepolia payments...");
ui.message("\nRequired Ledger Settings:");
ui.message("1. Enable 'Blind signing' in Ethereum app settings");
ui.message("2. When prompted, approve the EIP-712 message on your Ledger");

const selected = await selectLedgerAccount(ui, "evm", 5);

if (!selected) {
  process.exit(0);
}

ui.message(`\nUsing account: ${selected.address}`);

const ledgerWallet = await createLedgerEvmWallet(
  ui,
  baseSepolia,
  selected.path,
);

const walletForPayment = {
  chain: ledgerWallet.chain,
  address: ledgerWallet.address,
  account: {
    signTypedData: async (params: {
      domain: Record<string, unknown>;
      types: Record<string, unknown>;
      primaryType: string;
      message: Record<string, unknown>;
    }) => {
      return await ledgerWallet.signTypedData(params as TypedDataDefinition);
    },
  },
};

const fetchWithPayer = wrapFetch(fetch, {
  handlers: [createPaymentHandler(walletForPayment)],
});

ui.message(`\nMaking payment request to ${url}...`);
ui.message("When prompted, confirm the transaction on your Ledger...");

const req = await fetchWithPayer(url);
ui.message(`Status: ${req.status}`);
ui.message(`Headers: ${JSON.stringify(Object.fromEntries(req.headers))}`);
const response = await req.json();
ui.message(`Response: ${JSON.stringify(response)}`);

await ledgerWallet.disconnect();
ui.message("\nSuccess! Ledger payment completed.");
await ui.close();

Step-by-Step Breakdown

1. Create User Interface

const ui = await createReadlineInterface(process);
Creates a CLI interface for interacting with the user during the payment process.

2. Select Ledger Account

ui.message("1. Enable 'Blind signing' in Ethereum app settings");
ui.message("2. When prompted, approve the EIP-712 message on your Ledger");

const selected = await selectLedgerAccount(ui, "evm", 5);
This function:
  • Scans the first 5 Ethereum accounts on your Ledger
  • Displays them in the terminal
  • Prompts you to select which account to use
  • Returns the selected account’s address and derivation path

3. Create Ledger Wallet

const ledgerWallet = await createLedgerEvmWallet(
  ui,
  baseSepolia,
  selected.path,
);
Connects to your Ledger device and creates a wallet interface that:
  • Uses the selected derivation path
  • Signs EIP-712 typed data on the device
  • Never exposes private keys to your computer

4. Adapt Wallet Interface

const walletForPayment = {
  chain: ledgerWallet.chain,
  address: ledgerWallet.address,
  account: {
    signTypedData: async (params: {
      domain: Record<string, unknown>;
      types: Record<string, unknown>;
      primaryType: string;
      message: Record<string, unknown>;
    }) => {
      return await ledgerWallet.signTypedData(params as TypedDataDefinition);
    },
  },
};
The payment handler expects a specific wallet interface with a signTypedData method that matches viem’s signature. The Ledger wallet’s signTypedData method has a slightly different signature, so this adapter:
  • Wraps the Ledger wallet’s signTypedData method
  • Accepts the parameters in the format expected by the payment handler
  • Casts them to TypedDataDefinition (from viem) for the Ledger wallet
  • Ensures type compatibility between the payment handler and Ledger wallet interfaces

5. Make Payment

const fetchWithPayer = wrapFetch(fetch, {
  handlers: [createPaymentHandler(walletForPayment)],
});

const req = await fetchWithPayer(url);
When a payment is required:
  1. The payment handler creates an EIP-712 typed data message
  2. The message is sent to your Ledger
  3. You physically confirm on the device
  4. The signed authorization is returned
  5. The facilitator submits the transaction

6. Cleanup

await ledgerWallet.disconnect();
Always disconnect from the Ledger when done to release the USB connection.

Ledger Setup Requirements

Ledger EIP-712 Documentation

Learn about EIP-712 structured data signing and how Ledger devices support it, including blind signing requirements.

Hardware Setup

  1. Connect your Ledger device via USB
  2. Unlock your Ledger with your PIN
  3. Open the Ethereum app on your Ledger

Software Settings

Critical: You must enable “Blind signing” in the Ethereum app settings:
  • Open Ethereum app on Ledger
  • Go to Settings
  • Enable “Blind signing”
  • This is required for EIP-712 signing

Why Blind Signing?

EIP-712 structured data signing requires “blind signing” because:
  • The message structure isn’t standardized in older Ledger firmware
  • The device shows hashes rather than full message content
  • Newer firmware may support EIP-712 natively (check your version)

EIP-712 Signing on Ledger

When you sign an EIP-712 message, your Ledger will display:
  • Domain separator hash
  • Message hash
  • Your address
Always verify:
  • The hashes match what you expect
  • Your address is correct
  • The payment amount (if shown)

Command Line Arguments

  • First argument: Server port (default: 4021)
  • Second argument: Endpoint path (default: weather)
Example:
tsx ledger-base-sepolia-payment.ts 4021 weather

Ledger vs Software Wallets

import { createLedgerEvmWallet } from "@faremeter/wallet-ledger";

const ledgerWallet = await createLedgerEvmWallet(
  ui,
  baseSepolia,
  "m/44'/60'/0'/0/0"  // Derivation path
);

// Requires physical confirmation on device
const walletForPayment = {
  chain: ledgerWallet.chain,
  address: ledgerWallet.address,
  account: {
    signTypedData: async (params) => {
      return await ledgerWallet.signTypedData(params);
    },
  },
};
Tradeoffs:
  • Ledger: Maximum security, requires physical device, slower for automation
  • Software: Fast, automated, but keys stored on computer

Security Considerations

  • Physical confirmation: Every EIP-712 signature requires device approval
  • Private keys never leave device: Keys stay secure even if your computer is compromised
  • Hash verification: Always verify the hashes shown on your Ledger screen
  • Blind signing warning: Understand what you’re signing when using blind signing

Error Handling

The example includes error handling for:
  • Missing Ledger connection
  • Server connection failures
  • Signature rejections on the device
  • Invalid derivation paths

Troubleshooting

“Blind signing disabled” error:
  • Enable blind signing in Ethereum app settings on your Ledger
“Wrong app open” error:
  • Make sure the Ethereum app (not Solana) is open on your Ledger
“Transaction rejected” error:
  • Check that you’re approving the correct transaction on the device
  • Verify the payment amount matches what you expect