Repository navigation
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
Draft
gabrielfior wants to merge 33 commits into
gabrielfior wants to merge 33 commits into
Conversation
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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, andbalancewith aprivateoption. Every money move shows a preview and waits for a yes.Depends on an unmerged edw PR
This branch pins
edw-coreand theedwbinary to ethereum/desktop-wallet#122 at1d04b61(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-rsis held at17917c6, the commit edw's own lockfile uses, because edw leaves it unpinned in its manifest.futures-timeris 3.0.3, becausewebsnark-rspinswasm-bindgen =0.2.108andfutures-timer3.0.4 pulls ajs-systhat 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, newprofile newforms, 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
--tail-callsor--expected-fresh-address.TailCallshas no public constructor. The shield half comes from a storedshield --preparerecord, the Aave half from the skill script, built in memory for one plan and never stored.shield --preparereserves 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 whosecallsandtail_callsdisagree is refused.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.EDW_TUI_INTERIM_SEPOLIA, because that flag guards the interim executor's own signing, not edw's.How it was verified
cargo fmt --check,cargo clippy --all-targets -D warningstests/fork_private_borrow.rs, ignored by default)edw balanceshows 100 DAI private spendable. The record is consumed with the UserOp hash. Driven from the TUI with qwen3:8bAn 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
unshieldusage error before signing (the rule that decides whether a failed borrow keeps its preparation depends on it).live_sepolia_private_borrowtest, which moves real funds.For the next maintainer
private-aave-borrowskill has its own script and manifest. It repeats about four Aave read helpers and the Sepolia addresses fromaave-v3-lend, because a script can only import from its own folder. A test pins those addresses equal.edw-tui. The results above are from local runs.🤖 Generated with Claude Code