Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7bd263a
chore(edw-tui): pin edw to PR 122 (1d04b61) and port to its CLI and core
gabrielfior Oct 27, 2026
a0adf97
refactor(edw-tui): one Review field instead of mutating + moves_value
gabrielfior Oct 27, 2026
70eb9fb
feat(edw-tui): Tornado command model, checked against edw-core's pools
gabrielfior Oct 27, 2026
765e861
feat(edw-tui): store prepared shields so a retry reuses them
gabrielfior Oct 27, 2026
bfab5e3
feat(edw-tui): shield, unshield, prepare_shield and next_fresh_addres…
gabrielfior Oct 27, 2026
3e1ba5f
test(edw-tui): hold the Tornado tools to the pinned edw and the review
gabrielfior Oct 27, 2026
cb8c963
docs(edw-tui): document the Tornado tools and their tests
gabrielfior Oct 27, 2026
0021972
feat(edw-tui): refuse Tornado tools on mainnet unless edw sends throu…
gabrielfior Oct 27, 2026
932051f
feat(edw-tui): a prepared shield can be consumed, and a consumed one …
gabrielfior Oct 27, 2026
fcb76e9
feat(edw-tui): a skill action may run from an unshield's fresh account
gabrielfior Oct 27, 2026
af229da
feat(edw-tui): private-aave-borrow skill, the Aave half of a private …
gabrielfior Oct 27, 2026
fd23ded
feat(edw-tui): PrivateBorrow, the checked plan for a private Aave borrow
gabrielfior Oct 27, 2026
3a0f9b3
feat(edw-tui): private borrow in the agent, one card, then edw unshie…
gabrielfior Oct 27, 2026
dd073ed
test(edw-tui): a private borrow through the agent, the card and the p…
gabrielfior Oct 27, 2026
958ccdd
fix(edw-tui): close the private borrow's log line, and keep a failed …
gabrielfior Oct 27, 2026
57c0b3e
test(edw-tui): the private borrow's real script and calls on a Sepoli…
gabrielfior Oct 27, 2026
7daaab2
docs(edw-tui): the private borrow, consumed preparations, the mainnet…
gabrielfior Oct 27, 2026
2abbc0e
feat(edw-tui): HeldEth, the profile's Tornado ETH notes and edw's rul…
gabrielfior Oct 28, 2026
bfcf3eb
test(edw-tui): mined Tornado notes on the fake Sepolia, and the check…
gabrielfior Oct 28, 2026
2c7686a
feat(edw-tui): a private borrow checks the profile's notes before any…
gabrielfior Oct 28, 2026
43972cd
docs(edw-tui): the private borrow's held-notes check
gabrielfior Oct 28, 2026
7dea182
fix(edw-tui): mine_eth_notes waits for the Deposit receipt
gabrielfior Oct 28, 2026
db5d4c7
test(edw-tui): the shipped skills, skill_set and records in tests/com…
gabrielfior Oct 28, 2026
c8f9406
test(edw-tui): rehearse the private borrow end to end on a Sepolia fork
gabrielfior Oct 28, 2026
30f83ab
docs(edw-tui): how to rehearse the private borrow on a Sepolia fork
gabrielfior Oct 28, 2026
d8846cc
docs(edw-tui): record the first live Sepolia fee against the 10% reserve
gabrielfior Oct 28, 2026
502a5a9
chore(edw-tui): trim comments added on this branch
gabrielfior Oct 28, 2026
e345129
docs(edw-tui): say what edw's balance and transfer do and why interim…
gabrielfior Oct 28, 2026
b83494a
fix(edw-tui): keep a borrow's preparation when edw refuses the unshie…
gabrielfior Oct 28, 2026
e24a308
fix(edw-tui): keep a successful shield preparation even when its answ…
gabrielfior Oct 28, 2026
81b8656
fix(edw-tui): refuse a shield preparation whose tail calls are not it…
gabrielfior Oct 28, 2026
fff142f
fix(edw-tui): require a private borrow's Aave plan to borrow exactly …
gabrielfior Oct 28, 2026
2d3fad0
fix(edw-tui): cut calldata out of edw's text when a Tornado command p…
gabrielfior Oct 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,893 changes: 1,769 additions & 124 deletions edw-tui/Cargo.lock

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions edw-tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ alloy-dyn-abi = "1.7.3"
alloy-json-abi = "1.7.3"

# INTERIM (src/interim): edw-core signs balance/transfer/swap until desktop-wallet ships
# those commands. The rev must equal edw::EDW_PINNED_REV (a test checks it); the alloy
# versions follow edw-core's own workspace so there is one copy of each.
edw-core = { git = "https://github.com/ethereum/desktop-wallet", rev = "038c9944c0efff46082a9d85fdc216fe5e6c738e" }
# those commands. src/tornado also checks shield and unshield amounts against its Tornado pool
# catalog, so edw-core stays when the interim goes. The rev must equal edw::EDW_PINNED_REV (a
# test checks it); the alloy versions follow edw-core's own workspace so there is one copy of each.
edw-core = { git = "https://github.com/ethereum/desktop-wallet", rev = "1d04b61ac665c3e71db3d33f6cc6411f99e169dd" }
alloy-network = "2.3.0"
alloy-primitives = "1.6.1"
alloy-provider = "2.3.0"
Expand Down
171 changes: 166 additions & 5 deletions edw-tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ crates with it and drives edw only.
```bash
# edw, at the revision the tool mapping is tested against (edw::EDW_PINNED_REV):
cargo install --git https://github.com/ethereum/desktop-wallet \
--rev 038c9944c0efff46082a9d85fdc216fe5e6c738e --locked edw
--rev 1d04b61ac665c3e71db3d33f6cc6411f99e169dd --locked edw

ollama pull qwen3:8b
cargo run
Expand Down Expand Up @@ -51,8 +51,10 @@ line breaks in a paste never send the message.

## Transfers (interim)

edw has no `balance`, `transfer` or `swap` command yet, so `src/interim/` does the work until it
does, signing with the pinned `edw-core` from edw's own encrypted store. The tools the model
edw now has `balance` and `transfer`, but `transfer` is ETH only and broadcasts without a dry run,
and edw has no `swap`. So `src/interim/` still runs the chat's `transfer`, `swap` and public
`balance` (the private side of `balance` reads edw's own document), signing with the pinned
`edw-core` from edw's own encrypted store. The tools the model
sees are the SwiftUI app's `transfer` and `swap`, byte for byte (a test compares them with
`wallet-macos/.../ToolDefinitions.swift`), plus the app's safety clause, so switching to edw's
commands later changes no evals.
Expand All @@ -71,11 +73,150 @@ input first gets an approval for exactly the amount, so a swap may be two or thr
transactions; the review lists them all, and they are sent in order, stopping at the first
failure. Only known tokens are traded.

## Shield and unshield (Tornado)

These tools run edw's own Tornado Cash commands (`src/tornado/`), as the sending profile (`P`):

| Tool | edw command | Asks first |
|---|---|---|
| `shield` | `shield --token T --amount-formatted N --from 0 --profile P` | yes, with a preview |
| `unshield` | `unshield --token T --amount-formatted N [--to A] --expected-fresh-address F --profile P` | yes, with a preview |
| `prepare_shield` | `shield --prepare --token T --amount-formatted N --profile P` | yes, unless already prepared |
| `next_fresh_address` | `next-fresh-address --peek --profile P` | no; it stores nothing |

`balance` with `private: true` runs `edw balance --detailed -- P`, which lists the shielded
holdings and each unspent note.

- Before anything runs, the harness checks the token and amount against the unlocked network's
pools, with edw-core's own catalog at the pinned rev. Sepolia has ETH (0.1, 1, 10) and DAI
(100). Mainnet has ETH, DAI, USDC, USDT and WBTC. `local` has none. The amount must be a sum of
pool sizes: 1.1 ETH is 1 + 0.1, and 1.15 is refused. A refused call never reaches a review.
- With `--non-interactive`, edw broadcasts without asking, so the review is the only gate. It
shows the exact command, the network and chain id, and the notes a shield makes. When edw
batches a shield into one user operation, which installs its EIP-7702 delegation on the
paying address, the review says so. For an unshield it names the fresh address it withdraws
to. That address goes to edw as `--expected-fresh-address`, so edw aborts if its next fresh
address is another one. An unshield's `to` passes the same invented-address guard as
`transfer`.
- Preparing reserves notes (it spends Tornado nonces), and preparing again reserves different
ones. So edw's `edw/shield-prepared` answer is saved before the model sees it, in
`$EDW_DATA_DIR/edw-tui/prepared-shields/<id>.json` (folder 0700, file 0600, never deleted). A
later `prepare_shield` with the same network, profile, token and amount returns the stored
one without asking. The model gets the id and the notes' public metadata, never the calls.
- A record is marked `consumed` once an unshield that ends with its calls may have put them on
chain: edw reported the user operation, or the fresh address was stored while the unshield ran
(edw stores it only once a bundler accepted the operation), or it could not be read afterwards.
An unshield edw refused as a usage error (`"class":"usage"`, among them the
`--expected-fresh-address` abort) marks nothing: edw raises those before it signs. A consumed
record is never reused; the next request prepares new notes. A declined or failed attempt marks
nothing, so the retry reuses the same notes.
- These files are wallet state, like edw's own database. A seed-only restore cannot recover
reservations that never reached the chain, and the wallet database must not be rolled back to
an older backup while exported calls may still be submitted (edw's own warning). Back them up
with the wallet's data folder.
- On mainnet every Tornado tool is refused unless edw sends through an anvil fork: the interim's
gate, judged on edw's own RPC (`EDW_TUI_MAINNET_FORK=1`, a loopback address, a node that reports
a fork URL). Sepolia needs no `EDW_TUI_INTERIM_SEPOLIA`: that opt-in is about the interim
signing, and edw signs these.
- `--tail-calls` is not a tool argument. 8B models mangle long hex, and a tail call runs
arbitrary code from the fresh account. Only harness code can attach one, from a stored
preparation (`PreparedShield::tail_calls`), for a skill plan the user reviewed. The log and
the model see `<n calls>` in its place.
- Tornado commands get 10 minutes instead of 60 s, because pool sync, proving and the bundler
receipt can take minutes.

### Private borrow (Sepolia)

The `private-aave-borrow` skill borrows DAI on Aave against shielded ETH and shields the DAI
again, in one user operation that edw signs and sends:

```text
edw unshield --token ETH --amount-formatted 1 --expected-fresh-address F --tail-calls "$AAVE,$SHIELD"
```

1. The model calls `private_aave_borrow(eth, amount)`. The harness refuses anything but DAI on
Sepolia (there Aave lends the very token the Tornado DAI pool takes), amounts that are not
pool sums, and an unshield the profile's notes cannot pay for (below).
2. It peeks the fresh address `F` (`next-fresh-address --peek`) and runs the skill's script with
`F` as `me`: `supply` is the unshielded ETH less a 10% fee reserve, `borrow` the DAI amount.
The script reads Aave (ETH taken as collateral, DAI lent now, the loan within the ETH's LTV)
and plans `depositETH(pool, $self)` with the ETH, then `borrow(DAI, amount, $self)`.
3. The plan is checked for the fresh account, read back from its calldata, and must be exactly
those two calls. It is built for this borrow only and never stored.
4. The DAI shield is prepared (`shield --prepare`), or a stored, unconsumed one reused. It has no
card of its own: preparing sends nothing.
5. The two halves are checked against each other: the borrowed token is the one the shield's
approvals are for, the borrow is exactly what the shield deposits, and the ETH supplied fits in
the unshield after the fee reserve. Any mismatch refuses the borrow with nothing sent.
6. One card shows the decoded Aave calls, the new DAI notes, the amount unshielded and `F`.
After a yes, edw unshields with the Aave calls and then the shield's, simulates the whole
operation and sends nothing if that fails.

The fee reserve is `FEE_RESERVE_BPS`, 10% of the amount: edw-core seeds its own fee quote with a
tenth of the sponsoring note, which is never larger than the amount. A larger proven fee makes edw
refuse before signing.

The first live run on Sepolia (public Pimlico, 0.1 ETH unshielded, 0.09 ETH supplied) proved a fee
of 0.009462 ETH, 94.6% of the 0.01 ETH reserve; the fork rehearsal had proven 0.0048 ETH. The gas
actually paid was about 0.00327 ETH, and the paymaster refunded the rest, so 0.00673 ETH (6.7% of
the unshield) stayed on the fresh address. The reserve is kept at 10%: edw has no way to quote its
fee without sending, so a computed reserve needs an upstream quote flag. A fee above the reserve
is refused before anything is sent.

Before the script runs, the harness reads the profile's notes (`edw balance --detailed`) and picks
them as edw will, with edw-core's own `withdrawal_notes` at the pinned rev (`src/tornado/held.rs`).
It refuses, with no preparation and no card, when the mined ETH notes cannot make the amount
exactly (notes are only unshielded whole), when they could only once pending notes are mined, or
when the notes edw picks are all in pools without a paymaster (on Sepolia, the 10 ETH pool). A
balance edw could not read refuses the borrow too. `tests/held_notes.rs` holds the check to the
binary's own answers.

The collateral and the debt belong to `F`, an address of the same wallet, not to the profile.
Repaying or withdrawing has to come from `F` later; this skill does not do it.

A skill action runs from a fresh account like this only with `account = "fresh"` in its
`[[action]]`; `$self` and `self_only` then mean that account. edw-tui uses it for this flow only,
and a plan checked for one kind of account is refused for the other.

#### Rehearsing it on a Sepolia fork

`tests/fork_private_borrow.rs` runs the whole flow for real on an anvil fork of Sepolia, with no
Sepolia funds and nothing sent to Sepolia:

```bash
npm install -g @pimlico/alto@0.0.21 # the bundler kohaku-rs's fork kit pins; or set ALTO_BIN
EDW_BIN=… cargo test --test fork_private_borrow -- --ignored --nocapture # edw, anvil, Docker
```

A new wallet whose only RPC is the fork and whose only bundler is a local alto shields two 0.1 ETH
notes into Sepolia's Tornado pool with `edw shield`. The agent then borrows 100 DAI against one of
them, as above: the real script in Docker, the real preparation, one card (approved), and edw
proving and sending the combined unshield. The test checks from chain state that one transaction
carried the user operation, and that the fresh address is an EIP-7702 account with Aave collateral
and at least 100 DAI of variable debt. It also checks that the Tornado DAI pool took exactly 100
DAI and that nothing left the profile's public address. edw must list a mined 100 DAI note, and
the preparation must be consumed. Then the fork's gas price goes to 1.5 and 3 times what the fee
reserve covers, and the same request must be refused before signing, with the note and the
preparation kept.

It prints each stage's time, edw's own log of the unshield, and the proven fee against the reserve.
edw prices the fee at the bundler's slow `maxFeePerGas + maxPriorityFeePerGas`, so the reserve
holds while that stays below `reserve / (gas limit x 1.01)`. On 2026-10-28 that was about 4.2 gwei
for a 0.1 ETH borrow (gas limit 2.38M), against 2 gwei quoted on the fork and 2.2 gwei from public
Pimlico's slow tier the same day. Gas above that is refused before signing, with nothing sent. Up
to about twice that, edw says the fee leaves too little for the Aave deposit; beyond it, the error
is the bundler's simulation revert, `ExecuteError` (`0x5a154675`) of the first tail call.

What it does not cover: the public Pimlico bundler (its gas limits, prices and acceptance), the
paymaster's deposit on Sepolia now rather than at the fork block, and the public RPC's limits
during edw's live sync. Pool history still comes from the Saga indexer, as it does live: the public
RPC no longer serves Sepolia logs that old, so the indexer cannot be turned off here.

## Skills

A skill is a folder under `skills/` that teaches the agent something new, like Claude's
skills. The system prompt lists each skill's name and one-line description; the model calls
`load_skill` to read its instructions, and only then are its tools offered. Three ship here:
`load_skill` to read its instructions, and only then are its tools offered. Four ship here:

- `defi-data`: past yields, TVL and volume of DeFi pools and lending markets, from
DefiLlama, GeckoTerminal and DexScreener (no API keys). Read-only. A chain may be written
Expand All @@ -95,6 +236,9 @@ skills. The system prompt lists each skill's name and one-line description; the
the simulation.
- Where Aave has borrowing of an asset turned off (WETH on its Sepolia market, as of October
2026), the skill says so instead of building the plan.
- `private-aave-borrow`: borrow DAI against shielded ETH and shield it again, on Sepolia, from an
unshield's fresh address (see [Private borrow](#private-borrow-sepolia)). Requires
`aave-v3-lend`.
- `safe-multisig`: read a [Safe](https://safe.global) by address on Ethereum, Gnosis Chain,
Sepolia, Base and others, and act on its next transaction: `safe_info` (owners, threshold,
modules), `safe_queue` (what is waiting for signatures, who has not signed), `safe_activity`
Expand Down Expand Up @@ -135,7 +279,7 @@ reads) and, optionally, `skill.toml`, which is what edw-tui enforces:
credit delegation (`approveDelegation`, the spender may borrow in the user's name), and it needs
`approve_to`.
- `[[read_tool]]` and `[[action]]`: scripts, their JSON schemas, and for actions the contracts
they may `approves`.
they may `approves`, and `account = "fresh"` for the private borrow's action.
- `[[review_read]]`: a view call on a pinned contract that every review shows, read in a
simulation before the plan and after it, with the uint outputs it names
(`show = { healthFactor = "18", debt = "8 USD" }`).
Expand Down Expand Up @@ -241,6 +385,23 @@ what the model sees shows up in review. Regenerate with `cargo run -- tools-dump
```bash
cargo test # unit + integration; edw-backed tests skip without edw
cargo test --test edw_contract # every tool against the pinned edw
cargo test --test tornado # shield, unshield and prepare_shield through the review, on an
# anvil that answers as Sepolia (needs edw, anvil)
cargo test --test tornado live -- --ignored --nocapture # a real Sepolia shield and unshield;
# moves real Sepolia ETH (EDW_TUI_LIVE_PHRASE, EDW_TUI_LIVE_RPC)
cargo test --test private_borrow # the private borrow through the card and the pinned edw, on the
# anvil that answers as Sepolia (edw, anvil; card tests need Docker)
cargo test --test private_borrow live -- --ignored --nocapture # a real one on Sepolia
# (EDW_TUI_LIVE_PHRASE with mined ETH notes, EDW_TUI_LIVE_ETH,
# EDW_TUI_LIVE_RPC, Docker)
cargo test --test fork_private_borrow -- --ignored --nocapture # the whole private borrow on a
# Sepolia fork with a local bundler, then refused at high gas
# (network; edw, anvil, alto, Docker; see Private borrow)
cargo test --test held_notes # the held-notes check refuses what the pinned edw's unshield
# refuses, on notes mined on that anvil (edw, anvil)
cargo test --test skills_private_borrow -- --ignored --nocapture # the real script on a Sepolia
# fork, then its calls and a 200 DAI shield's from one account
# (network; EDW_TUI_SEPOLIA_RPC, Docker, anvil)
cargo test --test interim # real ETH sends on a throwaway anvil (needs anvil)
cargo test --test e2e_transfer # the real binary in a pseudo-terminal: alice sends bob 0.1 ETH;
# screenshots in target/e2e-screenshots/, snapshots in tests/snapshots/
Expand Down
Loading
Loading