langchain-erc20
LangChain tools for ERC-20 primitives: balances, allowances, transfers, approvals, and native wrap/unwrap, as execution plans an EOA can sign or a smart-contract wallet can batch.
0.1.0 (alpha). The public API may still change before 1.0. EIP-2612 permit is detected by
supports_permitbut the signing flow is not implemented yet.
What it is
A standalone toolkit for the ERC-20 surface and its real-world variants, usable by EOAs and by any smart-contract wallet (ERC-4337, ERC-7579, ERC-6900, Safe, or bespoke).
Every write tool returns an ordered execution plan rather than a bare transaction, because
(to, value, data) is the last point at which every account type still agrees:
- an EOA transaction is that plus nonce, gas and fees
- an ERC-7579
Executionis exactly that - a Safe
MultiSendentry is that plus an operation byte - an ERC-4337 UserOp wraps a batch of them in the account's own
callData
What it is not
- Not a DEX. No routers, pools, quotes or price logic — that is langchain-uniswap-v2.
- It never signs, holds keys, or broadcasts. That is the consumer's job.
- It ships no bundled token registry. A wrong address in one is a silent, unrecoverable loss of funds, and keeping such a table correct across chains, bridged variants and redeployments is a full-time job. You pass your own.
- No token discovery or approval auditing. Both need an indexer, not an RPC.
Install
pip install langchain-erc20
Quick start
Both modes are shown together deliberately: this package serves both, and an EOA-only example would give the wrong impression.
EOA
from langchain_erc20 import ERC20Toolkit
toolkit = ERC20Toolkit.for_chain(
1,
rpc_url="https://your-node",
tokens={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
)
tools = {t.name: t for t in toolkit.get_tools()}
plan = tools["transfer"].invoke(
{
"token": "usdc",
"to": "0x...recipient",
"from_address": "0x...your_eoa",
"amount": "25.5",
}
)
for tx in plan["transactions"]:
tx.pop("gas_estimated") # metadata, not a transaction field
signed = account.sign_transaction(tx)
w3.eth.send_raw_transaction(signed.raw_transaction)
Pass your own rpc_url, as both examples here do. for_chain falls back to a bundled public
endpoint when you don't, and those are free gateways shared by every user of this package, offered
with no availability guarantee. They exist so for_chain(1) works in one line while you try the
library out. Anything beyond that should supply its own endpoint — ERC20Toolkit.for_chain(1, rpc_url=...) or ERC20Toolkit(rpc_url=...) — and treat the bundled value purely as a fallback.
If a bundled endpoint is unreachable, for_chain says so and tells you this.
Smart-contract wallet
toolkit = ERC20Toolkit.for_chain(1, rpc_url="https://your-node", tx_mode="calls")
tools = {t.name: t for t in toolkit.get_tools()}
plan = tools["approve"].invoke(
{
"token": "0x...token",
"spender": "0x...spender",
"from_address": "0x...your_smart_account",
"amount": "100",
}
)
executions = [(c["to"], c["value"], bytes.fromhex(c["data"][2:])) for c in plan["calls"]]
send_batch_user_op(executions) # one atomic transaction
Execution modes
tx_mode="eoa" (default) |
tx_mode="calls" |
|
|---|---|---|
calls |
populated | populated |
transactions |
signable, sequential nonces | None |
| Nonce / gas / fee RPC calls | yes | zero |
calls mode makes no eth_getTransactionCount and no eth_estimateGas on purpose. A 4337 nonce
is EntryPoint.getNonce(sender, key), a 2D nonce unrelated to an EOA transaction count; and
eth_estimateGas with from set to a smart account simulates the account calling itself as an
EOA, which is not how the EntryPoint invokes it, so the number is wrong even when it succeeds.
The plan shape
{
"calls": [
{
"to": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
"value": 0,
"data": "0x095ea7b3...",
"role": "approve",
"description": "Set USDT allowance for 0x7a25... to 0",
},
],
"transactions": [...], # or None in calls mode
"chain_id": 1,
"summary": {...}, # whole-unit amounts, safe to show a user
}
role is approve, approve_reset, or action. Roles let a consumer validate a plan before
submitting it — for example a wallet with a spending-limit hook checking that no approval is left
standing — and let a UI describe it.
data is a hex string rather than bytes so plans stay JSON-serialisable: these are LangChain tool
returns and must survive being written into an agent transcript.
Tools
Read
| Tool | Returns |
|---|---|
get_token_metadata(token) |
address, name, symbol, decimals, total_supply, total_supply_base |
get_balance(token, owner) |
amount, amount_base, decimals, symbol |
get_native_balance(owner) |
amount, amount_base, symbol |
get_allowance(token, owner, spender) |
amount, amount_base, is_unlimited |
is_balance_sufficient(token, owner, amount) |
is_sufficient, balance, required, shortfall |
is_allowance_sufficient(token, owner, spender, amount) |
as above, for the allowance |
supports_permit(token) |
supported, standard — eip2612, dai, unknown or null |
Write — all return plans
| Tool | Plan |
|---|---|
transfer(token, to, from_address, amount) |
[action] |
transfer_all(token, to, from_address) |
[action], balance read at build time |
transfer_from(token, owner, to, from_address, amount) |
[action] |
batch_transfer(token, transfers, from_address) |
[action × N] |
approve(token, spender, from_address, amount / unlimited) |
[approve] or [approve_reset, approve] |
revoke_approval(token, spender, from_address) |
[approve_reset] |
wrap_native(from_address, amount) |
[action], amount carried as value |
unwrap_native(from_address, amount) |
[action] |
Every write tool also accepts amount_base for exact base units, and nonce to set the starting
nonce in EOA mode.
Amounts
amount accepts a float or a string. Prefer strings for large or precise values: a float
cannot represent 18 decimal places, and 0.1 + 0.2 is the cheapest possible way to send the wrong
amount. Conversion truncates toward zero and never rounds up, since rounding up can overspend or
exceed an allowance; when truncation loses precision the summary says amount_truncated: true.
Reads return both amount and amount_base so a consumer building its own calldata never has to
re-derive decimals.
Token compatibility
The ERC-20 standard is, in practice, a suggestion. Each of the following is a real token that
breaks a naive implementation, and each is handled here and covered by tests in
tests/test_compat.py.
| Reality | Token | How this package handles it |
|---|---|---|
transfer/approve return nothing |
USDT, BNB, OMG | Write functions are only ever encoded, never called, so empty returndata is never decoded. ERC20_NO_RETURN_ABI is exported for consumers who dry-run writes themselves. |
approve reverts while an allowance stands |
USDT | zero_first_approvals="auto" reads the allowance and emits [approve_reset, approve] when needed. Also closes the generic front-running window. |
name/symbol return bytes32 |
MKR, most pre-2018 tokens | Retried against a bytes32 ABI, null-stripped and UTF-8 decoded. Unreadable metadata becomes null rather than failing the call. |
decimals() absent |
a few | Never assumed to be 18. Raises and points at decimals_overrides, because guessing 18 on a 6-decimal token sends 10¹² times the intended amount. |
| Allowance stored in fewer bits | UNI (96 bits) | unlimited=True encodes exactly 2**256 - 1; anything above the uint256 ceiling raises before encoding. Some tokens reject even the maximum and need a concrete amount. |
| Fee-on-transfer | SafeMoon-likes | Summaries say amount_sent, never amount_received — the package cannot know how much arrives. |
| Rebasing | stETH, AMPL | transfer_all records balance_read_at_block and warns its fixed amount can go stale. |
| Blocklists, pauses, ERC-777 hooks | USDC, USDT, many | Not detectable in advance. See the warning below. |
Preflight is not a guarantee. With
preflight=True(the default) every write tool checks balances and allowances before building, and raises naming the exact shortfall. It cannot see blocklists, pauses, transfer hooks or reentrancy. A passing preflight means the transfer is not obviously impossible — not that it will succeed.
ERC-4337
This package owns exactly one step:
| Step | Owner |
|---|---|
| 1. Decide the calls | this package — plan["calls"] |
2. Encode into the account's callData |
consumer (account-specific) |
| 3. Fill the UserOp (sender, 2D nonce, initCode, paymaster) | consumer / AA SDK |
| 4. Estimate gas | bundler (eth_estimateUserOperationGas) |
5. Sign userOpHash |
consumer's signer |
| 6. Submit and poll | bundler |
Step 2 is account-specific: ERC-4337 standardises the UserOperation struct and the EntryPoint, not the account's execute interface. ERC-7579, ERC-6900, Safe, Kernel and LightAccount each differ.
Counterfactual (not-yet-deployed) accounts work: the address is deterministic and can hold tokens
before deployment, so balance reads succeed and nothing in calls mode requires code at the
address.
Note permit is effectively EOA-only. EIP-2612 verifies with ecrecover, which cannot validate
a smart-contract signature. Smart accounts should batch [approve, action] atomically instead,
which achieves the same thing without a signature.
Supported chains for wrapped-native
for_chain(chain_id) supplies a wrapped-native address and a public RPC for: Ethereum (1),
Sepolia (11155111), Optimism (10), Unichain (130), Monad (143), X Layer (196), World Chain (480),
BSC (56), Polygon (137), Base (8453), Arbitrum (42161), Avalanche (43114) and Zora (7777777).
Every address is verified against the live chain by scripts/verify_networks.py, which checks
bytecode, symbol(), decimals() and the presence of deposit()/withdraw(uint256), following
EIP-1967 proxies where needed. Unichain, World Chain, Zora, Base and Optimism all share
0x4200000000000000000000000000000000000006 — that is the OP Stack's deterministic predeploy, not
a copy-paste error, and each was verified separately.
Celo is deliberately absent: CELO is natively an ERC-20 with no wrapping step, so wrap_native
has no meaning there and for_chain(42220) fails loudly rather than guessing.
Everything except wrap_native/unwrap_native works on any EVM chain via the main constructor.
Development
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
.venv/bin/ruff check .
.venv/bin/ruff format --check .
.venv/bin/pyright --pythonpath .venv/bin/python
.venv/bin/python -m pytest -q
Re-verify the wrapped-native addresses against live chains:
.venv/bin/python scripts/verify_networks.py
License
MIT
Release files for langchain-erc20 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| langchain_erc20-0.2.0.tar.gz | 37.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langchain_erc20-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 72.9 kB
Release files / langchain_erc20-0.2.0.tar.gz
| Download URL | langchain_erc20-0.2.0.tar.gz |
|---|---|
| Size | 37.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1856deab384aadcd69e36e7b51474f16c0bf90c0b3a4beb00abff53f1d8de6c3
|
|
BLAKE2b-256 checksum How to use checksums |
16cca719bd74ca9bf7cdc715a0f457d5185afd4b8eb6ed7d63f467931380fab6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|
Release files / langchain_erc20-0.2.0-py3-none-any.whl
| Download URL | langchain_erc20-0.2.0-py3-none-any.whl |
|---|---|
| Size | 35.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
e9e3ea76829b83591b39a77570df509d625192681e3e8f07fb69d1836aa64c21
|
|
BLAKE2b-256 checksum How to use checksums |
3a48b2b651640c0d83a32987f54e08695681d8eeb62761cbd5ced7b10788f9a3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|