> For the complete documentation index, see [llms.txt](https://help.multisig.ledger.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://help.multisig.ledger.com/guides/api-guides/5.-erc20-token-transfers.md).

# 5. ERC20 Token Transfers

## 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:

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

## Configuration

{% tabs %}
{% tab title="TypeScript" %}

```typescript
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";
```

{% endtab %}

{% tab title="Python" %}

```python
import os
import requests
from web3 import Web3
from eth_abi import encode as abi_encode

# Chain / service config
CHAIN_ID = 11155111  # Sepolia
TX_SERVICE_URL = f"https://app.multisig.ledger.com/api/safe-transaction-service/{CHAIN_ID}"
RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com"

# Safe config
SAFE_ADDRESS = "0xYourSafeAddress"
OWNER_PRIVATE_KEY = "your-private-key"  # testnet only

session = requests.Session()
session.headers.update({"accept": "application/json"})

w3 = Web3(Web3.HTTPProvider(RPC_URL))
```

{% endtab %}

{% tab title="curl" %}

```ini
# These examples show the HTTP payloads you’d send with curl.
# Any signing / hashing must be done client-side.

CHAIN_ID=11155111
TX_SERVICE_URL=https://app.multisig.ledger.com/api/safe-transaction-service/11155111
SAFE_ADDRESS=0xYourSafeAddress
OWNER_ADDRESS=0xOwnerAddress
TOKEN_ADDRESS=0xTokenContractAddress
```

{% endtab %}
{% endtabs %}

The `TX_SERVICE_URL` points at the **Ledger-hosted** Transaction Service. Transactions proposed here appear in the [Ledger Multisig UI](https://app.multisig.ledger.com/).

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

## Step-by-step

{% stepper %}
{% step %}

### 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:

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const erc20Abi = parseAbi([
  "function transfer(address to, uint256 amount)",
  "function approve(address spender, uint256 amount)",
  "function balanceOf(address owner) view returns (uint256)",
]);
```

{% endtab %}

{% tab title="Python" %}

```python
# We’ll encode calls using:
# - 4-byte selector = keccak256("transfer(address,uint256)")[:4]
# - ABI-encoded args (address, uint256)

TRANSFER_SIG = "transfer(address,uint256)"
APPROVE_SIG = "approve(address,uint256)"
BALANCE_OF_SIG = "balanceOf(address)"
```

{% endtab %}

{% tab title="curl" %}

```http
# ABI encoding is always client-side.
# curl is only used to call:
# - the Transaction Service REST API
# - your JSON-RPC endpoint (optional)
```

{% endtab %}
{% endtabs %}
{% endstep %}

{% step %}

### 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.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const TOKEN_ADDRESS = "0xTokenContractAddress";
const RECIPIENT = "0xRecipientAddress";
const AMOUNT = 1000n * 10n ** 18n; // 1000 tokens (assuming 18 decimals)

const transferCalldata = encodeFunctionData({
  abi: erc20Abi,
  functionName: "transfer",
  args: [RECIPIENT as `0x${string}`, AMOUNT],
});

// transferCalldata starts with the 4-byte selector for transfer(address,uint256):
// 0xa9059cbb...
```

{% endtab %}

{% tab title="Python" %}

```python
from eth_utils import keccak, to_bytes

TOKEN_ADDRESS = "0xTokenContractAddress"
RECIPIENT = "0xRecipientAddress"
AMOUNT = 1000 * 10**18  # 1000 tokens (assuming 18 decimals)

selector = keccak(text=TRANSFER_SIG)[:4]
args = abi_encode(["address", "uint256"], [RECIPIENT, AMOUNT])

transfer_calldata = Web3.to_hex(selector + args)
# starts with 0xa9059cbb...
```

{% endtab %}

{% tab title="curl" %}

```http
# You’ll need `transfer_calldata` computed client-side.
# It becomes the `data` field in the Transaction Service proposal payload.
```

{% endtab %}
{% endtabs %}
{% endstep %}

{% step %}

### Encode approve calldata (optional)

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

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const SPENDER = "0xDeFiProtocolAddress";

const approveCalldata = encodeFunctionData({
  abi: erc20Abi,
  functionName: "approve",
  args: [SPENDER as `0x${string}`, AMOUNT],
});
```

{% endtab %}

{% tab title="Python" %}

```python
SPENDER = "0xDeFiProtocolAddress"

selector = keccak(text=APPROVE_SIG)[:4]
args = abi_encode(["address", "uint256"], [SPENDER, AMOUNT])

approve_calldata = Web3.to_hex(selector + args)
```

{% endtab %}

{% tab title="curl" %}

```http
# Same rule as transfer:
# compute `approve_calldata` client-side, then propose it via REST.
```

{% endtab %}
{% endtabs %}
{% endstep %}

{% step %}

### 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.

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const protocolKit = await Safe.init({
  provider: RPC_URL,
  signer: OWNER_PRIVATE_KEY,
  safeAddress: SAFE_ADDRESS,
});

const apiKit = new SafeApiKit({
  chainId: CHAIN_ID,
  txServiceUrl: TX_SERVICE_URL,
});

const safeTransaction = await protocolKit.createTransaction({
  transactions: [
    {
      to: TOKEN_ADDRESS,           // Token contract, NOT the recipient
      value: "0",                  // No ETH sent - value is in calldata
      data: transferCalldata,      // Encoded transfer(to, amount)
      operation: OperationType.Call,
    },
  ],
});
```

{% endtab %}

{% tab title="Python" %}

```python
# Python doesn’t have an official Protocol Kit.
# Practical flow:
# 1) Build (to, value, data, operation) as below.
# 2) Compute safeTxHash + signature using a Safe SDK / signer.
# 3) Propose via Transaction Service REST API.

TOKEN_ADDRESS = "0xTokenContractAddress"

tx_fields = {
    "to": TOKEN_ADDRESS,
    "value": "0",
    "data": transfer_calldata,
    "operation": 0,  # CALL
}
```

{% endtab %}

{% tab title="curl" %}

```http
# Proposed txs are stored off-chain in the Transaction Service.
# You submit the full tx payload + safeTxHash + an owner/delegate signature.
```

{% endtab %}
{% endtabs %}
{% endstep %}

{% step %}

### Sign and propose

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

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const signerAddress = await protocolKit.getSafeProvider().getSignerAddress();
const safeTxHash = await protocolKit.getTransactionHash(safeTransaction);
const signature = await protocolKit.signHash(safeTxHash);

await apiKit.proposeTransaction({
  safeAddress: SAFE_ADDRESS,
  safeTransactionData: safeTransaction.data,
  safeTxHash,
  senderAddress: signerAddress!,
  senderSignature: signature.data,
});
```

{% endtab %}

{% tab title="Python" %}

```python
# You must compute these client-side:
# - safe_tx_hash: bytes32 hash for the Safe tx
# - signature: owner signature over safe_tx_hash
safe_tx_hash = "0xYourSafeTxHash"
signature = "0xOwnerSignature"
owner_address = "0xOwnerAddress"

payload = {
    "safe": SAFE_ADDRESS,
    "to": tx_fields["to"],
    "value": tx_fields["value"],
    "data": tx_fields["data"],
    "operation": tx_fields["operation"],
    "gasToken": "0x0000000000000000000000000000000000000000",
    "safeTxGas": "0",
    "baseGas": "0",
    "gasPrice": "0",
    "refundReceiver": "0x0000000000000000000000000000000000000000",
    "nonce": "0",
    "contractTransactionHash": safe_tx_hash,
    "sender": owner_address,
    "signature": signature,
}

resp = session.post(
    f"{TX_SERVICE_URL}/v1/safes/{SAFE_ADDRESS}/multisig-transactions/",
    json=payload,
)
resp.raise_for_status()
```

{% endtab %}

{% tab title="curl" %}

```http
POST /v1/safes/{SAFE_ADDRESS}/multisig-transactions/
Accept: application/json
Content-Type: application/json

{
  "safe": "0xYourSafeAddress",
  "to": "0xTokenContractAddress",
  "value": "0",
  "data": "0xTransferCalldata",
  "operation": 0,
  "gasToken": "0x0000000000000000000000000000000000000000",
  "safeTxGas": "0",
  "baseGas": "0",
  "gasPrice": "0",
  "refundReceiver": "0x0000000000000000000000000000000000000000",
  "nonce": "0",
  "contractTransactionHash": "0xYourSafeTxHash",
  "sender": "0xOwnerAddress",
  "signature": "0xOwnerSignature"
}
```

{% endtab %}
{% endtabs %}

> **REST API:** `POST /v1/safes/{address}/multisig-transactions/`
> {% endstep %}

{% step %}

### Execute (when threshold is met)

Once enough owner signatures are collected:

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const publicClient = createPublicClient({
  chain: sepolia,
  transport: http(RPC_URL),
});

const pendingTx = await apiKit.getTransaction(safeTxHash);

if ((pendingTx.confirmations?.length ?? 0) >= pendingTx.confirmationsRequired) {
  const result = await protocolKit.executeTransaction(pendingTx);
  const receipt = await publicClient.waitForTransactionReceipt({
    hash: result.hash as `0x${string}`,
    timeout: 120_000,
  });
  if (receipt.status === "reverted") {
    throw new Error("ERC-20 execution reverted on-chain");
  }
  console.log("Token transfer executed. Tx hash:", result.hash);
}
```

{% endtab %}

{% tab title="Python" %}

```python
# Execution is an on-chain call to the Safe contract.
# Recommended: execute with a Safe SDK that:
# - rebuilds the Safe tx
# - packs signatures in address-sorted order
#
# You can still check whether the Transaction Service collected enough signatures:
resp = session.get(f"{TX_SERVICE_URL}/v1/multisig-transactions/{safe_tx_hash}/")
resp.raise_for_status()
pending = resp.json()

if len(pending.get("confirmations") or []) < pending["confirmationsRequired"]:
    raise RuntimeError("Not enough confirmations to execute yet")
```

{% endtab %}

{% tab title="curl" %}

```http
GET /v1/multisig-transactions/{safeTxHash}/
Accept: application/json
```

{% endtab %}
{% endtabs %}
{% endstep %}
{% endstepper %}

## 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

{% tabs %}
{% tab title="TypeScript" %}

```typescript
// Safe sends tokens to a recipient
{ to: tokenAddress, value: "0", data: encodeTransfer(recipient, amount) }
```

{% endtab %}

{% tab title="Python" %}

```python
# Same structure as the REST proposal fields:
tx_fields = {"to": token_address, "value": "0", "data": transfer_calldata, "operation": 0}
```

{% endtab %}

{% tab title="curl" %}

```http
# Include these fields in the Transaction Service proposal payload:
# to=token_address, value="0", data=transfer_calldata, operation=0
```

{% endtab %}
{% endtabs %}

### Approve a spender

{% tabs %}
{% tab title="TypeScript" %}

```typescript
// Safe approves a DeFi protocol to spend tokens
{ to: tokenAddress, value: "0", data: encodeApprove(spender, amount) }
```

{% endtab %}

{% tab title="Python" %}

```python
tx_fields = {"to": token_address, "value": "0", "data": approve_calldata, "operation": 0}
```

{% endtab %}

{% tab title="curl" %}

```http
# Same proposal shape as transfer, but data=approve_calldata
```

{% endtab %}
{% endtabs %}

### Batch: approve + swap

Combine approval and a DeFi interaction in a single Safe transaction using [batch transactions](broken://pages/fc84917ad6adffbe187b956d3c5a4499c87463fc):

{% tabs %}
{% tab title="TypeScript" %}

```typescript
const transactions = [
  { to: tokenAddress, value: "0", data: approveCalldata, operation: OperationType.Call },
  { to: routerAddress, value: "0", data: swapCalldata, operation: OperationType.Call },
];

const safeTx = await protocolKit.createTransaction({ transactions });
```

{% endtab %}

{% tab title="Python" %}

```python
# Batching is normally built with the TypeScript Protocol Kit (MultiSend wrapping).
# In Python you’d typically propose the final wrapped tx once you have:
# - to=multi_send_address
# - operation=1 (DELEGATECALL)
# - data=multi_send_calldata
```

{% endtab %}

{% tab title="curl" %}

```http
# Same constraints as Python:
# the MultiSend calldata must be computed client-side.
```

{% endtab %}
{% endtabs %}

## 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:

{% tabs %}
{% tab title="TypeScript" %}

```typescript
// USDC: 6 decimals
const usdcAmount = 1000n * 10n ** 6n; // 1000 USDC
```

{% endtab %}

{% tab title="Python" %}

```python
# USDC: 6 decimals
usdc_amount = 1000 * 10**6  # 1000 USDC
```

{% endtab %}

{% tab title="curl" %}

```http
# Decimals affect the uint256 you encode in calldata.
# For USDC, multiply by 10^6 (not 10^18).
```

{% endtab %}
{% endtabs %}

* **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

{% content-ref url="/pages/ad004d924a03999a4d86299df8aad3597bf05f42" %}
[6. Multi Signature Flow](/guides/api-guides/6.-multi-signature-flow.md)
{% endcontent-ref %}
