Skip to content

feat(edw-tui): Tornado shield/unshield tools and a private Aave borrow (pins edw PR 122) - #108

Draft
gabrielfior wants to merge 33 commits into
tui-walletfrom
feat/edw-tornado-erc20-private-borrow
Draft

gabrielfior wants to merge 33 commits into
tui-walletfrom
feat/edw-tornado-erc20-private-borrow

Conversation

@gabrielfior

Copy link
Copy Markdown
Contributor

What this does for a user

In edw-tui, a user on Sepolia can now say "privately borrow 100 DAI against 0.1 ETH". They get one confirmation card. After a yes, edw sends a single UserOp that unshields their Tornado ETH note to a fresh account, supplies the ETH to Aave, borrows DAI, and shields the DAI back into Tornado. The model never sees or writes calldata.

The generic Tornado tools are available to any chat turn, not only this skill: shield, unshield, prepare_shield, next_fresh_address, and balance with a private option. Every money move shows a preview and waits for a yes.

Depends on an unmerged edw PR

This branch pins edw-core and the edw binary to ethereum/desktop-wallet#122 at 1d04b61 (ERC-20 shield and unshield). It should not merge until that PR lands and the pin moves to the merged commit. If #122 is force-pushed, the pinned SHA may stop resolving.

Two lockfile choices follow from the pin and are worth a look:

  • kohaku-rs is held at 17917c6, the commit edw's own lockfile uses, because edw leaves it unpinned in its manifest.
  • futures-timer is 3.0.3, because websnark-rs pins wasm-bindgen =0.2.108 and futures-timer 3.0.4 pulls a js-sys that needs a newer one.

Bumping the pin also needed an edw-tui port: edw's core was reorganised and its CLI changed (JSON with --non-interactive, EDW_DATA_DIR, new profile new forms, an unlock that creates an empty wallet). The commit message of the pin bump lists each break.

Related: desktop-wallet#127 asks for a fee quote that does not sign or send.

How it works

  • Harness-only tail calls. The model cannot pass --tail-calls or --expected-fresh-address. TailCalls has no public constructor. The shield half comes from a stored shield --prepare record, the Aave half from the skill script, built in memory for one plan and never stored.
  • One card. It shows the decoded Aave calls, the shield note, the unshield amount, and the fresh address. edw simulates the whole operation again before signing and aborts without broadcasting if that fails.
  • Prepared shields are stored. shield --prepare reserves notes and burns Tornado nonces, and a second prepare reserves different notes. The harness saves the record (0700 folder, 0600 file, atomic write) before returning anything. A retry reuses it. A record is marked consumed once a bundler accepted the UserOp. A record whose calls and tail_calls disagree is refused.
  • Checked before anything is reserved. The harness reads the profile's notes and picks them with edw-core's own withdrawal_notes, so it refuses exactly what edw would: notes that cannot sum to the amount, notes not yet mined, or no note that can sponsor gas. The borrow must equal the shield amount exactly.
  • Mainnet is refused for every Tornado tool unless edw is pointed at a loopback anvil fork. Sepolia needs no EDW_TUI_INTERIM_SEPOLIA, because that flag guards the interim executor's own signing, not edw's.
  • Fee reserve. The Aave supply is the unshield amount minus a fixed 10% reserve. edw has no way to quote its fee without sending, so a computed reserve needs an upstream flag (#127 above).

How it was verified

Check Result
cargo fmt --check, cargo clippy --all-targets -D warnings clean
Full suite with the pinned edw binary 354 passed, 0 failed, 20 ignored across 26 test binaries
Fork rehearsal (tests/fork_private_borrow.rs, ignored by default) passes, about 80 s: a fork of Sepolia with Sepolia's Tornado pools, paymaster, EntryPoint v0.8, Aave and DAI, plus a local alto bundler. One transaction to the EntryPoint with one successful UserOp; Aave collateral and 100 DAI debt on the fresh account; the Tornado DAI pool gains exactly 100 DAI; the record is consumed
One live run on Sepolia worked as one on-chain transaction (type 4, one UserOp). Aave shows $360 collateral and $100 debt, health factor 2.97. edw balance shows 100 DAI private spendable. The record is consumed with the UserOp hash. Driven from the TUI with qwen3:8b

An independent review of the branch returned nine findings. Five were real and are fixed with a red then green test each (consuming a shield after edw refused for a moved fresh address; a preparation lost if edw's output did not parse; a check that did not cover the bytes broadcast; an unbounded borrow; calldata reaching the model when edw printed no JSON). One was already handled by the UI. Three were deliberately left (a lock held across a prompt, duplicated peek code, mainnet gating on reads).

Fee finding

On the live run edw proved a fee of 0.009462 ETH, 94.6% of the 0.01 ETH reserve. The fork rehearsal proved 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. A fee above the reserve is refused before anything is sent.

Not verified

  • A second live run, a different gas price, or mainnet.
  • Real models other than one qwen3:8b run. gemma4 is untested.
  • Public Pimlico behaviour at other times: its gas limits and quoted price, and whether it keeps accepting 7702 plus paymaster operations.
  • The paymaster's deposit stays solvent.
  • A future edw revision keeping every unshield usage error before signing (the rule that decides whether a failed borrow keeps its preparation depends on it).
  • The ignored live_sepolia_private_borrow test, which moves real funds.
  • The review previews as rendered in a real terminal. Tests assert the events.

For the next maintainer

  • The private-aave-borrow skill has its own script and manifest. It repeats about four Aave read helpers and the Sepolia addresses from aave-v3-lend, because a script can only import from its own folder. A test pins those addresses equal.
  • Prepared-shield files are wallet state. A seed-only restore cannot recover reservations that never reached the chain, and the wallet database must not be rolled back while exported calls may still be submitted.
  • The repo's CI does not cover edw-tui. The results above are from local runs.

🤖 Generated with Claude Code

gabrielfior and others added 30 commits October 27, 2026 15:43
desktop-wallet PR 122 (feat/erc20-shield-unshield) is 21 commits past the
old pin 038c994. It reorganized edw-core and changed the CLI that the tool
adapter drives.

edw-core: `profile::simple`, `ScopedDatabaseExt`, `resolve_mnemonic` and
`SupportedNetwork` are gone. The interim executor now opens edw's store with
`instance::Instance::open`, finds profiles with `Instance::profile(s)`,
derives the m/44'/60'/<profile>'/0/0 signer from `Instance::recovery_phrase`
(the same path edw's CLI uses), and reads the RPC from `Instance::active_rpc`.
`NetworkPreset` replaces `SupportedNetwork`. edw gives a new `local` wallet
no RPC, so `local` still falls back to 127.0.0.1:8545. The test wallet
seeders use `Instance::open_or_create` + `import_profile`; e2e_transfer now
reuses the shared seeder instead of its own copy.

CLI adapter (`edw::run`, `build_argv`, new `edw::run_tool`):
- Every command runs with `--non-interactive`, so the model and the log get
  edw's JSON document. Its human output no longer shows profile indices,
  which `list_profiles`, `add_profile` and `rename_profile` depend on.
- The data dir goes in `EDW_DATA_DIR`. edw no longer reads `DATA_DIR` and
  would otherwise fall back to ~/.edw.
- Only `unlock` gets `EDW_DECRYPTION_PASSWORD`. edw now unlocks any command
  that sees it, which would make `lock` a no-op.
- `new_mnemonic` maps to `profile new --new-phrase [--long-phrase]`.
  `add_profile` maps to `profile new --phrase-of <mnemonic>/0`.
- `run_tool` keeps two promises the tool descriptions make and edw dropped:
  the first unlock of a network creates mnemonic 0 / profile 0 (via
  `profile new --new-phrase`), and `add_profile` without `mnemonic` is
  refused when the wallet has several.
- The interim reads the unlocked network from `config view` JSON.

Tests: the contract and agent-loop tests assert on edw's JSON facts, not
its wording. edw never prints a recovery phrase off a terminal now, so
they assert that nothing needed redacting. The agent-loop flow unlocks
`local`, because `profile new --phrase-of` scans the chain on networks with
an RPC (47 s measured once on sepolia).

Cargo.lock: kohaku-rs is pinned to 17917c6, the rev in edw's own lock,
because edw's manifest leaves it unpinned. websnark-rs pins
wasm-bindgen =0.2.108, so js-sys/web-sys/wasm-bindgen were re-resolved to
edw's versions, and futures-timer goes from 3.0.4 to 3.0.3 (gloo-timers 0.2)
because rig-core's 3.0.4 needs js-sys >= 0.3.91. No crate was removed. The
105 new crates come from edw-core's kohaku dependencies.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The two booleans encoded three states (runs without asking, confirm the
command, confirm a preview the tool wrote), and the fourth combination
meant nothing. `Review` names the three. The contract lists derive from it
unchanged: `tools-dump` output is byte-identical.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`tornado::Command` is one edw Tornado command: shield, a prepared shield,
unshield, peek at the next fresh address, or the private balance. It is
built from a tool call and the unlocked network, and turned into argv for
the harness's sending profile.

- `Token` is the closed set edw can shield (ETH, DAI, USDC, USDT, WBTC).
- `PoolAmount` checks a token and amount with edw-core's own catalog and
  splitter (`resolve_asset`, `parse_amount`, `deposit_pools_for_asset`), at
  the rev the binary is pinned to. So the harness refuses exactly what edw
  would, naming the network's tokens or pool sizes, and the review can show
  the notes a shield makes.
- An unshield's `Destination` is the fresh address, `--to`, or tail calls.
  Tail calls and `--expected-fresh-address` have no tool argument. Only
  harness code sets them, and `display` counts tail calls instead of
  printing calldata.
- `for_model` gives the model edw's JSON document only, without the stderr
  lines where edw echoes tail calls.

Not wired to any tool yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`shield --prepare` reserves Tornado notes, and preparing again reserves
different ones. `tornado::prepared::Store::reuse_or` looks for a stored
preparation with the same network, profile, token and amount and returns
it. Otherwise it runs the caller's prepare and saves edw's answer before
returning it. A lock serializes this so two calls cannot both reserve.

Records live in `<edw data dir>/edw-tui/prepared-shields/<id>.json`. edw
only reads folders named after a network. The folder is 0700, each file
0600, written through a synced temp file and a rename, and never deleted.
The id is `shield-` plus 10 hex characters of the document's sha256.
- An answer that is not a version-1 `edw/shield-prepared` document for the
  requested chain, token and note count is still kept, as
  `unreadable-<digest>.json`, since its notes are reserved anyway. The
  caller gets an error, and nothing is reused from it.
- If the record cannot be written, the caller gets an error and no id.
- `summary` is what the model sees: id, network, token, amount and the
  notes' public metadata. No calls and no calldata.
- `tail_calls()` returns the deposit calls as `TailCalls` for the phase-3
  composition. `get(id)` accepts only well-formed ids.

The fixture is a real answer from the pinned edw (200 DAI, on anvil
running with Sepolia's chain id).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…s tools

The model can now use Tornado Cash through edw's CLI. The tools run as the
harness's sending profile, after `tornado::Command` has checked the call
against the unlocked network's pools.

- `shield` and `unshield` (Review::Preview) open a transfer-style card:
  prepare, your review, send. The review shows the exact edw command and
  a preview: amount, token, network and chain id, and the notes a shield
  makes. It also says when edw batches a shield into one user operation
  that installs its EIP-7702 delegation. An unshield first peeks at the
  next fresh address, names it in the review, and passes it to edw as
  `--expected-fresh-address`, so edw aborts if it would use another
  address. edw broadcasts with `--non-interactive` without asking, so
  this review is the only gate. An unshield `to` goes through the same
  invented-address guard as `transfer`.
- `prepare_shield` (Review::Preview) goes through the prepared-shield
  store. A stored preparation for the same deposit comes back with
  `"reused": true` and no question. Otherwise the user is asked, edw
  reserves notes, and the record is saved before the model gets the id
  and summary. The model never sees calldata.
- `next_fresh_address` runs `next-fresh-address --peek`. It is read-only.
- `balance` takes `private`. When it is true the call runs
  `edw balance --detailed -- <profile>` instead of the interim reader,
  which only reads public balances.

Tornado commands run with a 10-minute timeout, because pool sync, proving
and the bundler receipt take longer than the usual 60 s. The model gets
edw's JSON document alone. Reading the unlocked network moves from the
interim into `edw::unlocked_network`. The interim keeps its own mainnet
and Sepolia gates on top.

The preamble drops "edw cannot yet shield, unshield". It now says what the
tools do, that they move real money, and that the user is always asked
first. CONTRACT_VERSION is 4, because "shield 1 ETH" is now a call instead
of a refusal. The scripted model maps the new requests. In agent_loop,
"shield 1 ETH" on `local` is refused before any review, because local has
no pools.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`common::fake_sepolia` gives a wallet whose RPC is a fresh anvil
answering with Sepolia's chain id, with edw's indexer off. The RPC is set
before the first profile exists. On that wallet the pinned edw resolves
Sepolia's pools and prepares real notes without reaching the network.

edw_contract (the adapter, against the binary):
- local: before an RPC is configured, every Tornado command fails (1,
  other: no RPC config). With anvil as the RPC, `next-fresh-address
  --peek` leaves the data dir byte-identical, while a stored
  `next-fresh-address` changes it. The harness refuses every token, and
  edw refuses `shield --prepare` as usage (2, "no ETH pools on chain
  31337").
- fake Sepolia: for each `Token` the harness and edw agree on which ones
  can be prepared (ETH, DAI). edw refuses the others and DOGE as usage.
  1.15 ETH is refused by both, by edw as (1, other). A 1.1 ETH preparation
  reserves exactly the notes the harness predicted, and the store accepts
  edw's real document. shield and unshield argv, including `--to`, the
  harness's tail calls and `--expected-fresh-address`, are accepted
  flag for flag: edw stops at the unfunded payer or at having no notes.
  `balance --detailed -- 0/0` lists `tornado_notes`. Every Tornado tool
  and `balance` must appear.

tests/tornado.rs (the agent with the scripted model, on fake Sepolia):
- prepare_shield: a declined prepare reserves nothing. An approved one
  is saved as 0600 and its log line has no deposit calldata. The retry is
  not asked about, comes back with the same id and `"reused": true`, and
  edw's pending private balance is 1.1 ETH, not 2.2.
- shield: a declined shield leaves the funded payer and the pool address
  untouched. An approved one moves 0.1 ETH to the 0.1 ETH pool's address.
- unshield: the review carries the peeked fresh address in the command
  (`--expected-fresh-address`) and the preview.
- USDC on Sepolia, 1.15 ETH and an unshield to the zero address never
  reach a review. The fresh-address peek and the private balance run
  without one.
- `live_sepolia_shield_then_unshield` is ignored by default. It documents
  what a real run needs (EDW_TUI_LIVE_PHRASE, optionally EDW_TUI_LIVE_RPC)
  and has never been run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
README gets a "Shield and unshield (Tornado)" section. It has a table of
the tools, their edw command and whether they ask first, and explains the
pool checks, the review as the only broadcast gate, the stored
preparations, why tail calls are harness-only, and the 10-minute timeout.
The Tests list gains `--test tornado` and the ignored live Sepolia run.

Cargo.toml: edw-core is no longer only the interim's. src/tornado checks
amounts against its pool catalog, so it stays when the interim goes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…gh a loopback anvil fork

Every Tornado call (shield, unshield, prepare_shield, next_fresh_address, private balance) now
goes through tornado::network, which holds mainnet to the interim's own gate: EDW_TUI_MAINNET_FORK=1,
a loopback RPC, and a node that reports an anvil fork. The RPC judged is the one edw itself
sends through (its active rpc-config), not EDW_TUI_RPC_URL, which only the interim uses.

Sepolia does not need EDW_TUI_INTERIM_SEPOLIA: that opt-in is about the interim signing, and edw
signs Tornado commands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…is never reused

A record gets `consumed` (what shows a bundler accepted an unshield ending with its calls) through
Store::consume, under the same lock as reuse_or. reuse_or skips consumed records, so the next
request prepares new notes; a failed or cancelled attempt marks nothing and stays reusable.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`account = "fresh"` on an `[[action]]` says its plan runs from the fresh address an unshield
withdraws to, as tail calls of edw's user operation. plan::check now takes the Account its `$self`
stands for (Sender or Fresh, named by the harness, never the script) and refuses a plan whose
action declares the other kind, so a sender's action never passes as a fresh account's and the
other way round. `self_only` keeps its meaning: only `$self`, now that account. The review names
it "the fresh account" instead of "you".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…borrow

One action, private_aave_borrow, that runs from an unshield's fresh account (`account = "fresh"`)
and requires aave-v3-lend. Its script gets the supply (wei), token and borrow (base units) from
edw-tui, checks Aave's own state (ETH taken as collateral, the token lent now, the loan within the
ETH's LTV at Aave's prices) and plans depositETH(pool, $self) with the ETH, then borrow(DAI, $self).
The manifest pins aave-v3-lend's Sepolia addresses and nothing else, and approves nothing: the
DAI it names is the Tornado DAI pool's token, whose approvals come from edw's preparation.

contract.json regenerated; CONTRACT_VERSION 5, since a private borrow is now a call.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tornado::borrow names the data a private borrow carries, in three typed steps:
- Request: the model's two amounts on Sepolia, ETH to unshield and DAI to borrow and shield,
  each a sum of pool sizes; anything but DAI on Sepolia is refused.
- AaveCalls: the skill's plan, checked for the fresh account and read back from its calldata,
  exactly depositETH then borrow, both for that account and the same pool. Built for one borrow,
  never stored.
- PrivateBorrow: the Aave calls plus a prepared shield. The Aave half must supply at most the
  unshield minus FEE_RESERVE_BPS (10%, edw-core's own sponsor-fee seed) and borrow the very token
  the shield's approvals are for, at least as much as it deposits. Checked once against edw-core's
  catalog before any note is reserved, and again against the stored record.

Its tail calls are the Aave calls and then the record's, byte for byte, and its command is the
unshield held to the fresh address. Its card shows the decoded calls, the new notes, the amount,
the fresh address and where the debt ends up, and no calldata.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ld with both halves

A skill action with `account = "fresh"` no longer goes through the interim's send path. The
harness checks the request (Sepolia, DAI, pool-sized amounts), peeks the fresh address, runs the
skill's script with that address as `me` and the supply and borrow it derived, checks the plan for
that account, prepares the DAI shield or reuses a stored one (no card of its own: it sends
nothing), checks the two halves against each other, and shows one card. After a yes, edw unshields
with the Aave calls and then the shield's as tail calls, held to the fresh address, and simulates it
all before signing.

The preparation is marked consumed when edw reports the user operation, or when the fresh address
was stored while the unshield ran (edw stores it once a bundler accepts) or cannot be read
afterwards; a decline or a plain failure leaves it for the retry. Skill scripts read the chain
through edw's own RPC (tornado::rpc), checked to be the unlocked chain.

The scripted model maps "privately borrow N DAI against M ETH" onto load_skill and the action.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…inned edw

On the anvil that answers as Sepolia, with the skill's script swapped for one that writes the
same plan without reading Aave (that chain has none):
- one card shows the decoded Aave calls for the fresh account, the new DAI note, the amount and
  the fresh address; a no sends nothing and keeps the preparation;
- a failed unshield (no notes) leaves the preparation unconsumed, and the retry reuses it: edw's
  pending DAI stays 100, not 200;
- a fresh address stored during the attempt retires the preparation, and the next attempt
  prepares new notes;
- the harness refuses USDC and amounts that are not pool sums before any card or preparation;
- the argv the harness builds, run as edw itself with a real `shield --prepare`, passes every
  flag and the tail-call parser and stops at "held notes cannot make 1000000000000000000 wei
  exactly";
- nothing the model was sent, and nothing in the log, carries approve, deposit, depositETH or
  borrow calldata (the recorder now keeps each request's chat history).

live_sepolia_private_borrow is the real thing, ignored, with the environment it needs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…consume inside the turn

The skill command's log entry now finishes once the plan and the shield are ready (fresh address
and preparation id), instead of staying "running" after a yes or a no. A preparation that cannot
be marked consumed is reported as a failed log line after the result, not as AgentEvent::Error,
which ends the UI's turn and abandons the card.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…a fork

Ignored by default (network, Docker, anvil). On an anvil fork of Sepolia, the shipped script plans
for a fresh account against Aave's own state, refuses 1000 DAI on 0.1 ETH (not enough collateral
at Aave's prices), and plans 0.9 ETH in and 200 DAI out for a 1 ETH unshield. From an account
holding only that ETH, its two calls and then a prepared 200 DAI shield's approvals and Tornado
deposits all succeed: the account ends with no DAI and the pool gains 200.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… gate and wallet state

README: the private-aave-borrow skill and its flow (one card, edw unshield with the Aave calls and
then the shield's, the 10% fee reserve and why), `account = "fresh"`, the consumed state, that
prepared-shield files are wallet state (a seed-only restore cannot recover unbroadcast
reservations; do not roll the wallet database back while exported calls may be submitted), the
Tornado mainnet gate, and the new test commands.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e for picking them

HeldEth reads `edw balance --detailed` into typed notes (a pool from edw-core's catalog, mined or
not) and answers whether they can pay for an unshield the way edw picks notes: it builds
edw-core's TornadoNote values and calls edw-core's own withdrawal_notes, at the rev the binary is
pinned to. So only mined notes count, the largest are taken first and must make the amount
exactly, and one picked note must be in a pool with a paymaster.

A refusal says which rule fired (CannotMake, NotMined, NoSponsor) and what is held and needed.
NotMined is CannotMake that counting the pending notes would fix. A failed command, or a balance
without `tornado_notes` (edw could not read them), is an error, never an empty list. Only ETH
notes are kept: edw lists a token's notes only while the profile shows that token.

The fixture is the pinned edw's own document on the fake Sepolia: mined 10 and 1 ETH notes and a
pending 0.1 ETH note.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… held to edw's own answers

The fake Sepolia now starts at block 5_594_410, above every Sepolia pool's deploy block, so edw's
pool sync reads its logs. mine_eth_notes prepares ETH notes with edw, puts a log-only contract at
each note's pool and emits Tornado's Deposit with the note's commitment: edw then lists the note
as mined, and its unshield picks it and goes on to prove (and fails later, as this chain has no
EntryPoint). The suites already on this chain pass unchanged.

held_notes.rs runs the pinned edw's unshield for 11 amounts across three wallets (no notes; 10
and 1 ETH mined with 0.1 pending; then nine more 1 ETH notes) and asserts HeldEth refuses exactly
what edw refuses while picking notes, for the same rule, and passes exactly what edw goes on to
prove. That includes 10 ETH beside ten 1 ETH notes, which edw refuses because it takes the 10 ETH
note (no paymaster) first. It also asserts the unit tests' fixture is what this edw lists for
those notes. Counting pending notes as mined makes it fail at 0.1 ETH.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… reservation or card

Before, a borrow the profile's notes could not pay for still ran the script, reserved DAI notes
(`shield --prepare`) and showed a card; edw refused only after the yes ("held notes cannot make
... exactly"). Now, right after the request is checked, the harness reads `edw balance --detailed`
and asks HeldEth whether the notes pay for the unshield as edw picks them. A refusal goes to the
model as a failed tool result, with nothing reserved, reviewed or sent:
- no mined notes, or mined notes that cannot make the amount: what is held, what is needed, and
  that notes are only unshielded whole;
- notes that would make it but are not mined yet;
- notes edw would pick that are all in pools without a paymaster.
A balance edw could not read refuses the borrow too; the check is never skipped.

Tests, through the Harness and the pinned edw on the fake Sepolia: a wallet with no notes, one
whose 0.1 ETH cannot make 1 ETH, one with only a pending 1 ETH note, one with a 10 ETH note asked
for 10 ETH, and one whose RPC is gone are each refused with no card, no stored preparation and no
DAI reserved in edw. The card tests now mine the 1 ETH note they unshield, so they reach the card
through the check. Two of them changed with it: edw now picks that note and proves, so the retry
test's unshield fails later (no EntryPoint on this chain) instead of at note selection, and the
fresh-address test's unshield is refused by edw's --expected-fresh-address guard, which comes
after note selection. Both still assert what they are about: a failed attempt keeps its
preparation, and a moved fresh address retires it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
README: the harness reads the profile's notes and picks them with edw-core's own withdrawal_notes
before the script runs, the three refusals (cannot make the amount, not mined yet, no paymaster),
that an unreadable balance refuses too, and the held_notes test that holds it to the binary.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
mine_eth_notes sent the Deposit log with eth_sendTransaction and read the receipt at once. anvil
answers before it mines, so under load the receipt was not there yet: held_notes failed once with
"the Deposit log landed" in a full `cargo test` while other anvil forks ran on the machine, and
passed four times out of four alone. The helper now sends through alloy and waits for the
receipt, and the assertion prints it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mon, and Harness::next_within

private_borrow.rs kept these three helpers to itself; the fork rehearsal needs them too, so they
move to tests/common unchanged. Harness::next_within waits for the next event as long as asked:
an unshield that proves and waits for its receipt can outlast next's 120 s.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
tests/fork_private_borrow.rs (ignored) runs the private borrow for real with no Sepolia funds.
tests/common/sepolia_fork.rs forks Sepolia with anvil and starts alto 0.0.21 (kohaku-rs's fork-kit
pin) for the EntryPoint v0.8 Sepolia already has. A new wallet gets the fork as its RPC and alto
as its only bundler: edw lets the public Pimlico default be removed once another URL is listed.

The test shields two 0.1 ETH notes into Sepolia's Tornado pool with edw. The agent (scripted
model, the shipped script in Docker, the real preparation, one approved card) borrows 100 DAI
against one, and edw proves and sends the combined unshield. From chain state: one transaction
to the EntryPoint carried the user operation (success, the Tornado paymaster, the fresh address
as sender, an EIP-7702 delegation), the fresh address has Aave collateral and at least 100 DAI
of variable debt, the Tornado DAI pool took exactly 100 DAI, and nothing left the profile's public
address. edw lists a mined 100 DAI note and the preparation is consumed. Then the fork's gas is
priced at 1.5 and 3 times what the 10% reserve covers. edw refuses both before signing, with
nothing sent and the note and the new preparation kept. It prints stage times, edw's own log of
the unshield, and the proven fee against the reserve.

Pool history comes from the Saga indexer, as live: the public RPC answers eth_getLogs at the
pools' deploy block with "pruned history unavailable" and caps a request at 50,000 blocks. The
fork mines 100 blocks first, so for 20 minutes Saga cannot publish blocks the fork has its own
deposits in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
README: installing alto at kohaku-rs's pin, the command, what the rehearsal does and checks, the
fee rule it prints (the reserve holds while the bundler's slow maxFee + maxPriorityFee stays
below reserve / (gas limit x 1.01), about 4.2 gwei for 0.1 ETH on 2026-10-28), how a fee above
the reserve is refused, and what it does not cover: public Pimlico, the paymaster's current
deposit, and the public RPC under edw's live sync. Pool history still comes from Saga.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… still runs transfer and swap

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ld before signing

A fresh address that moved while the card was open made edw abort on --expected-fresh-address
before proving or signing, yet the harness saw the moved address and marked the shield consumed,
so its never-deposited notes were dropped for good. An edw/error of class usage now marks nothing:
at the pinned rev every usage failure of unshield comes before edw signs. A peek that fails after
any other failure still consumes the record, since reusing calls that landed is the worse mistake.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…er does not parse

edw has reserved the notes by the time shield --prepare succeeds, but an answer whose first line
was not its JSON document was handed to the model whole (calls included) and never stored, so the
reservation was lost. Both prepare paths now share one helper that runs edw itself: a failure is
the model's error, as before, and any success goes to the store, which keeps an answer it cannot
read as unreadable-*.json. The pinned edw prints one compact JSON line on stdout, so the parser
itself is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
gabrielfior and others added 3 commits October 28, 2026 13:40
…s checked calls

The approval a borrow is checked against is read from the document's calls, but the unshield
broadcasts its tail_calls string, and nothing tied the two. save now decodes tail_calls and
requires the same targets, data and values, in order; any other answer is kept as unreadable and
never offered. The borrow test that swapped the approved token now swaps it in both fields.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…what the shield deposits

The skill script is untrusted and is told to borrow the shield amount, yet a plan borrowing more
passed, leaving the fresh account with more debt than the user asked for and the excess unshielded
on it. The check is now equality, in both the plan check and the check against the prepared
shield's approvals.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rints no document

The model saw only edw's JSON document when there was one, but an argument error or a crash
leaves none, and then the whole output went to the model and the log. The pinned edw's clap error
for a bad --tail-calls echoes every tail call, approve and deposit calldata included. Without a
document, every hex run longer than a 32-byte hash is now cut and a long text keeps its last
1000 characters, where edw says why it stopped. The prepare and peek failure paths go through the
same boundary.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant