5. ERC20 Token Transfers

Encode ERC-20 function calls and propose them as Safe transactions through the Ledger Multisig API.

What you'll learn

  • Encode ERC-20 transfer() and approve() calldata using viem

  • Create a Safe transaction that calls a token contract (value = 0, data = calldata)

  • Sign and propose the token transaction to the Transaction Service

Prerequisites

  • Node.js 18+

  • A Safe on a supported chain where you control an owner key

  • A private key for a Safe owner: testnet only

  • An ERC-20 token balance in the Safe (for actual execution)

  • viem: used for ABI encoding

Install dependencies:

npm install @safe-global/api-kit @safe-global/protocol-kit @safe-global/types-kit viem

Configuration

import SafeApiKitModule from "@safe-global/api-kit";
import SafeModule from "@safe-global/protocol-kit";
import { OperationType } from "@safe-global/types-kit";
import { encodeFunctionData, parseAbi } from "viem";
import { createPublicClient, http } from "viem";
import { sepolia } from "viem/chains";

const SafeApiKit =
  typeof SafeApiKitModule === "function"
    ? SafeApiKitModule
    : (SafeApiKitModule as unknown as { default: typeof SafeApiKitModule }).default;
const Safe =
  typeof SafeModule === "function"
    ? SafeModule
    : (SafeModule as unknown as { default: typeof SafeModule }).default;

const CHAIN_ID = 11155111n;
const TX_SERVICE_URL = `https://app.multisig.ledger.com/api/safe-transaction-service/${CHAIN_ID}`;
const RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com";
const SAFE_ADDRESS = "0xYourSafeAddress";
const OWNER_PRIVATE_KEY = "your-private-key";

The TX_SERVICE_URL points at the Ledger-hosted Transaction Service. Transactions proposed here appear in the Ledger Multisig UIarrow-up-right.

Supported chains: Ethereum (1), Optimism (10), BSC (56), Polygon (137), Base (8453), Arbitrum (42161), Sepolia (11155111).

Step-by-step

1

Define the ERC-20 ABI

Parse the standard ERC-20 function signatures you'll use. viem's parseAbi creates a typed ABI from human-readable signatures:

2

Encode transfer calldata

Encode the transfer(address, uint256) function call. This produces the raw calldata bytes that the Safe will send to the token contract.

3

Encode approve calldata (optional)

The same pattern works for approve. It's commonly used before interacting with DeFi protocols:

4

Create a Safe transaction with the encoded data

The key difference from a plain ETH transfer: set value to "0" (no ETH is sent) and data to the encoded calldata. The to field is the token contract address, not the recipient.

5

Sign and propose

The signing and proposal flow is identical to any other Safe transaction:

REST API: POST /v1/safes/{address}/multisig-transactions/

6

Execute (when threshold is met)

Once enough owner signatures are collected:

Key concepts

How ERC-20 calls work through a Safe:

When a Safe sends tokens, it doesn't transfer ETH. It calls a function on the token contract. The Safe is the msg.sender, and the token contract's internal accounting moves the balance.

Safe → token.transfer(recipient, amount) ↓ Token contract: balances[Safe] -= amount balances[recipient] += amount

In Safe transaction terms:

Field
ETH Transfer
ERC-20 Transfer

to

Recipient address

Token contract address

value

Amount in wei

"0"

data

"0x"

Encoded transfer(to, amount)

operation

Call

Call

Common ERC-20 patterns

Transfer tokens

Approve a spender

Batch: approve + swap

Combine approval and a DeFi interaction in a single Safe transaction using batch transactions:

Tips and pitfalls

  • to is the token contract, not the recipient. A common mistake is setting to to the address you want tokens sent to. The to field must be the token contract address. The actual recipient is encoded in the data field.

  • value must be "0" for ERC-20 calls. Unless you're also sending ETH alongside the token call (rare), set value to zero.

  • Check the token's decimals. Not all tokens use 18 decimals. USDC uses 6, WBTC uses 8. Encoding the wrong amount is a common source of errors:

Example:

  • Use viem for encoding. The encodeFunctionData function from viem handles ABI encoding correctly and provides TypeScript type safety. Don't manually construct calldata.

  • Any contract call follows this pattern. ERC-20 is just one example. The same approach works for any smart contract interaction: encode the function call as data, set to to the contract address, and set value to the ETH amount needed (usually "0").

  • Tested playground script is propose-only. The verified example script uses a dummy token address and intentionally does not execute on-chain. For execution, use a real ERC-20 token contract address on your target chain.

Next steps

6. Multi Signature Flowchevron-right

Last updated