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.3.0 (alpha). The public API may still change before 1.0 — 0.3.0 moved
gas_estimatedout of each transaction dict onto the plan, see the changelog. EIP-2612 permit is detected bysupports_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"]:
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 |
gas_estimated |
one bool per transaction | None |
| Nonce / gas / fee RPC calls | yes | zero |
| Simulation | free, from the gas estimate | one eth_call, deployed senders only |
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.
It does still simulate the first call, with one deliberate eth_call preceded by an eth_getCode,
so that "a plan certain to revert is never built" holds for smart accounts too and not just for
EOAs, which get it free from gas estimation. A sender with no code yet is skipped: its balance and
allowances belong to an address that does not hold them until the same UserOp deploys it.
estimate_gas=False turns simulation off in both modes.
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
"gas_estimated": [...], # per tx: live estimate or static table; None in calls mode
"chain_id": 1,
"summary": {...}, # whole-unit amounts, safe to show a user
}
A transaction dict holds transaction fields and nothing else, so it signs exactly as returned —
acct.sign_transaction(plan["transactions"][0]), no preparation step. Anything else would break the
first thing an EOA consumer does: eth_account validates its input and rejects an unrecognised key
with TypeError: Unknown kwargs. Whether each gas limit came from a live estimate or the static
fallback is a real question, so it is answered in plan["gas_estimated"], index for index, rather
than smuggled into the dict.
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.Simulation is the stronger check, and separate: it runs the call rather than reading balances, so it does catch a blocklist or a pause. Every plan gets it — for free from the gas estimate in EOA mode, and from one deliberate
eth_callin calls mode — but only against the chain as it is now, and only for the first call in a plan.
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.3.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.3.0.tar.gz | 42.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| langchain_erc20-0.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 81.0 kB
Release files / langchain_erc20-0.3.0.tar.gz
| Download URL | langchain_erc20-0.3.0.tar.gz |
|---|---|
| Size | 42.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
71ee36aa29f49201059bbee66ef63548d25ad475f8e14b7282896a11ea89c673
|
|
BLAKE2b-256 checksum How to use checksums |
1b36dbfb0793e6889de9a2ba4b1976619d6ffbbe3a5d3341dc05e6b9f5b0f13d
|
| 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.3.0-py3-none-any.whl
| Download URL | langchain_erc20-0.3.0-py3-none-any.whl |
|---|---|
| Size | 38.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
64d78492f18d86889ce9346e605aa6d3cde61bbd1c5389b09cbe93041148ae02
|
|
BLAKE2b-256 checksum How to use checksums |
93827994a734355d68fb2d3abb63a0b6110aeefe9608ad3a5193440d06238637
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.12.3
|