> 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 %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://help.multisig.ledger.com/guides/api-guides/5.-erc20-token-transfers.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
