feat(1inch): add Aqua registry descriptor (ship, dock) - #28
Open
mbertin-ledger wants to merge 1 commit into
Open
feat(1inch): add Aqua registry descriptor (ship, dock)#28mbertin-ledger wants to merge 1 commit into
mbertin-ledger wants to merge 1 commit into
Conversation
🧪 Clear Signing Tests
This PR is from a fork. A maintainer needs to add the Once approved, the tests will run automatically and post screenshots here. |
fsamier
marked this pull request as ready for review
August 13, 2026 07:54
🔍 ERC-7730 Analysis Complete📊 0 descriptor(s) analyzed 📥 View workflow run & download reports ✅ Status: ALL CLEARNo critical issues detected — this PR is safe to merge (based on automated analysis). |
Aqua is an allowance registry: it holds no tokens, and `ship` is an approval
that lets one app pull up to `amounts[i]` of `tokens[i]` straight out of the
maker's wallet. The naive descriptor for it renders `strategy` as raw hex and
`amounts` as bare integers, which is not clear signing — `strategy` is the field
that decides where the funds actually go, and hex is not reviewable.
Aqua's core only ever does `keccak256(strategy)`; it never decodes or dispatches
it. But the app receiving the authorization does. On mainnet there are exactly 2
apps, both `AquaSwapVMRouter` (1inch SwapVM), and both are deployed at identical
addresses on all 12 chains, so one allowlist covers every deployment. Its
strategy blob is `abi.encode(ISwapVM.Order{address maker, MakerTraits traits,
bytes data})`, where `traits` packs a receiver address plus four maker hook
flags, and `data` carries the hook payloads and the VM program. A manipulated
blob can redirect the entire spending cap via `receiver`.
So gate the fields that cannot be rendered safely, and display the ones that
bound the loss:
ship()
shown strategy.[44:64] liquidity owner, typed "wallet" so the device can
check it is the signer's own account
amounts.[] per-token cap via tokenAmount + tokenPath
tokens.[] array iteration (this is the loss bound)
gated app mustMatch the 2 known AquaSwapVMRouter addresses
strategy.[0:32] == 0x20 \ blob really is abi.encode(Order), so
strategy.[96:128] == 0x60 / the slices below mean what we claim
strategy.[64:65] == 0x40 no hooks, no unwrap, no zero-amount-in
strategy.[65:66] == 0x00 no attacker-chosen hook target
strategy.[76:96] == 0 receiver defaults to maker
hidden strategy.[160:] the VM program
dock() app displayed, not gated — revocation is always safe and must
never be made harder to review than granting
multicall() bytes[] elements as embedded calldata, calleePath @.to
Anything outside those constraints falls back to blind signing, which is the
intended failure mode: the guards are what make the rest of the screen truthful.
The program is excluded rather than shown. It is up to ~256 bytes of hex that
nobody can act on — the deployed router's opcode table does not even match
main's OpcodeList.sol — so displaying it only teaches people to scroll past hex.
Note `visible: "optional"` would not have helped: the device VisibleType has only
ALWAYS / MUST_BE / IF_NOT_IN, and the converter special-cases only "never", so
"optional" silently serializes as ALWAYS. The disclosure that an unreviewable
strategy is being authorized is carried by the intent, "Authorize SwapVM
strategy", rather than by dumping the bytes.
dock() keeps strategyHash visible even though it is opaque and cannot lose
funds, because it is the only field identifying which strategy is being revoked:
app and tokens are already shown, and makers run many strategies against the
same app and pair. It also matches how this registry already handles the same
shape, cancelOrder(bytes32 orderHash) in common-AggregationRouterV6.json.
A hook is a fixed, typed `IMakerHooks` callback at four settlement points, not
arbitrary calldata — the selector and argument layout are fixed, and the blob
chooses only the callee and one opaque `bytes` argument. It cannot move tokens;
SwapVM does the transfers. That is narrower than it first looks, but the callee
is still attacker-choosable when the HAS_TARGET bit is set, hence the
strategy.[65:66] guard.
Mainnet replay, all 107,432 Shipped events since deployment, blocks
25567141..25846972 — full population, not a sample:
clear-signed 107,412 / 107,432 99.981%
blind-signed 20 / 107,432 0.019% (tx-level: 99.975%)
The 20 misses are 3 makers using non-standard encodings (14 bare programs with
no Order wrapper, 5 flat static tuples, 1 blob whose maker != sender) — exactly
the cases that should not clear sign. The flag byte was 0x40 and the hook-target
byte 0x00 across all 107,418 well-formed blobs, so neither guard costs traffic.
Cross-chain replay. The app allowlist and the receiver guard hold on every chain
with traffic, with zero app addresses outside the two known routers, and
strategy.[65:66] is 0x00 everywhere. The flag byte is not universal: makers off
mainnet enable maker hooks (0x4c = useAqua|hasPostTransferInHook|
hasPreTransferOutHook, 0x44 = useAqua|hasPreTransferOutHook).
Split by account type, because it changes the conclusion — every hook-bearing
maker is a deployed contract, and no EOA has ever set a hook flag (a hook on an
EOA is a no-op, there is no code to call):
ships clear blind rate
EOA / 7702-delegated 819 813 6 99.3%
deployed contract 219 66 153 30.1%
Those 6 are malformed blobs on Base, not hooks. The hook users are a handful of
market-maker vaults — the 0x4c makers on Base and Arbitrum run byte-identical
18,245 byte code at different addresses, i.e. one operator across four chains —
whose transactions originate from the contract, not from a device prompt. So for
accounts that sign with a key, which is the only population a hardware wallet is
in the loop for, the strict guard costs 0.7% and none of it is hook-related.
Per-chain raw rates (63% Arbitrum, 12.5% Avalanche) invite the wrong conclusion.
Optimism, BSC, Gnosis, Linea and Sonic have no reachable full-history explorer;
narrow RPC windows showed no ships, so they are unverified rather than empty.
Deliberately NOT widening the flag byte to accept 0x44 / 0x4c. Every observed
hook-bearing strategy has no explicit hook target, which per MakerTraits in
swap-vm main would make the hook call the maker's own address and look benign.
But that reading does not reconcile with the deployed router: its opcode table
does not match main's OpcodeList.sol, and `_getDataSlice` expects `data` to open
with a 40 byte tokenA/tokenB prefix while those blobs carry only 32 bytes of
data. Loosening a value gate on a layout that cannot be confirmed against the
deployed bytecode is not worth 178 ships from automated vaults. Worth asking
1inch to confirm the deployed traits/data layout; if it matches,
["0x40","0x44","0x4c"] plus the HAS_TARGET guard restores ~100% everywhere with
the redirect vector still shut.
pull/push/simulate/rescueFunds/transferOwnership are deliberately left without a
format so they blind sign. simulate() delegatecalls an arbitrary address and
always reverts; it is an off-chain eth_call helper and must never be presented as
a signable action.
Toolchain note: the guards use `mustMatch`, per specs/erc-7730.md and the current
erc7730-v2.schema.json. erc7730 <=1.0.10 spelled the same key `mustBe` and its
calldata converter silently dropped the rule on labelled fields, which would have
turned these guards into decorative raw-hex rows. Both are fixed in
python-erc7730 (fix/v2-visibility-mustmatch), which now serializes them to the
FIELD struct's VISIBLE / CONSTRAINT tags that app-ethereum already supports, so
the device enforces them: `erc7730 calldata` emits MUST_BE plus the constraint
bytes for all six guards, and two CONSTRAINT tags for the app allowlist.
Chain 130 (Unichain) is listed in the deployments but the tool's network table
has no id for it, so no calldata descriptor is emitted for that chain yet. The
contract and both apps are deployed there; this is a `ledger_network_id` gap, not
a descriptor problem.
Tests use three real mainnet transactions (direct ship, standalone dock, and a
multicall batching dock+ship). The multicall case asserts only the outer intent —
nested element rendering should be confirmed on-device and its assertions
tightened afterwards.
paoun-ledger
force-pushed
the
feat/1inch-aqua
branch
from
August 27, 2026 15:57
24eca66 to
217f8f3
Compare
|
successfully tested but depends on LedgerHQ/python-erc7730#335 |
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.
Jira: LBD-707
Summary
registry/1inch/calldata-Aqua.jsoncovering 1inch Aqua'sship()anddock()— the LP-signed entry points into the shared-liquidity registry.pull()/push()/simulate()are taker- or owner-gated and out of scope for this v1.strategy(bytes, inship) is shownraw/always: it's SwapVM VM bytecode (opcode + arg-length + args stream, seesrc/libs/VM.solin1inch/aqua), not a decodable struct — no ERC-7730 v2 format can disassemble it, and the demoXYCSwapstruct in the ticket doesn't match what real traffic actually uses.tokens[]/amounts[]inship, andtokens[]indock, use the#.-array-group pattern (same asfigment/calldata-figment-batch-deposit.json) to pair them by index.Address correction (LBD-707)
The ticket's address,
0x499943e74fb0ce105688beee8ef2abec5d936d31, is 1inch's earlier developer-preview deployment (created Nov 2025, inactive since March 2026). This descriptor targets0x1111113ccf1426a8e30e2bff5e005d929bf6a90ainstead — the production registry from the July 27, 2026 public launch:1inch/aquaREADME's Deployments tableeth_getCodeTest plan
ship/dockselectors (0xf50b870f,0x28defc17) verified against keccak256 of the format keyserc7730 lintpasses — only warnings for out-of-scope functions (pull,push,simulate,rescueFunds, ownership admin fns)onBehalf-style trap, all fund-relevant params (app,tokens,amounts,strategyHash) arealwaysvisibleshipshipping WBTC+USDT, adockwithdrawing WBTC+USDC) rather than the synthetic calldata in the ticket, since the production contract now has real activity🤖 Generated with Claude Code