Metadata-Version: 2.4
Name: langchain-erc20
Version: 0.3.0
Summary: LangChain tools for ERC20 primitives: balances, allowances, transfers, approvals, and native wrap/unwrap, as execution plans an EOA can sign or a smart-contract wallet can batch.
Author-email: conrad.japhet@gmail.com
License-Expression: MIT
Project-URL: Repository, https://github.com/Conrad-sudo/langchain-erc20
Project-URL: Issues, https://github.com/Conrad-sudo/langchain-erc20/issues
Keywords: langchain,erc20,web3,ethereum,evm,tokens,agent,tools,erc4337,smart-wallet
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: web3>=7.0
Requires-Dist: langchain-core>=0.3
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff==0.16.1; extra == "dev"
Requires-Dist: pyright==1.1.411; extra == "dev"
Dynamic: license-file

# langchain-erc20

[![CI](https://github.com/Conrad-sudo/langchain-erc20/actions/workflows/ci.yml/badge.svg)](https://github.com/Conrad-sudo/langchain-erc20/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/langchain-erc20)](https://pypi.org/project/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_estimated` out
> of each transaction dict onto the plan, see the changelog. EIP-2612 permit is detected by
> `supports_permit` but 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 `Execution` is exactly that
- a Safe `MultiSend` entry 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](https://github.com/Conrad-sudo/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

```bash
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

```python
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

```python
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

```python
{
    "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_call` in 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

```bash
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:

```bash
.venv/bin/python scripts/verify_networks.py
```

## License

MIT
