Skip to content

feat(1inch): add Aqua registry descriptor (ship, dock) - #28

Open
mbertin-ledger wants to merge 1 commit into
masterfrom
feat/1inch-aqua
Open

feat(1inch): add Aqua registry descriptor (ship, dock)#28
mbertin-ledger wants to merge 1 commit into
masterfrom
feat/1inch-aqua

Conversation

@mbertin-ledger

@mbertin-ledger mbertin-ledger commented Aug 10, 2026

Copy link
Copy Markdown
Member

Jira: LBD-707

Summary

  • Adds registry/1inch/calldata-Aqua.json covering 1inch Aqua's ship() and dock() — 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, in ship) is shown raw/always: it's SwapVM VM bytecode (opcode + arg-length + args stream, see src/libs/VM.sol in 1inch/aqua), not a decodable struct — no ERC-7730 v2 format can disassemble it, and the demo XYCSwap struct in the ticket doesn't match what real traffic actually uses.
  • tokens[]/amounts[] in ship, and tokens[] in dock, use the #.-array-group pattern (same as figment/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 targets 0x1111113ccf1426a8e30e2bff5e005d929bf6a90a instead — the production registry from the July 27, 2026 public launch:

  • Matches the address in the 1inch/aqua README's Deployments table
  • Deployed via CREATE2 factory 8 days before the public launch (consistent with a coordinated multi-chain launch)
  • Live, continuous transaction activity through today; the ticket's address has had none since March
  • Confirmed deployed identically (same bytecode) on all 12 chains from the ticket via eth_getCode
  • Confirmed not a proxy (Etherscan reports no implementation; bytecode contains the full dispatch logic directly)

Test plan

  • ship/dock selectors (0xf50b870f, 0x28defc17) verified against keccak256 of the format keys
  • erc7730 lint passes — only warnings for out-of-scope functions (pull, push, simulate, rescueFunds, ownership admin fns)
  • Manual security audit against the checklist (OpenAI analyzer key is expired) — no onBehalf-style trap, all fund-relevant params (app, tokens, amounts, strategyHash) are always visible
  • Verified against 2 real on-chain transactions (a ship shipping WBTC+USDT, a dock withdrawing WBTC+USDC) rather than the synthetic calldata in the ticket, since the production contract now has real activity
  • Device screenshot verification (not run in this environment — OPENAI key expired, no simulator access here)

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

🧪 Clear Signing Tests

Waiting for maintainer approval to run tests.

This PR is from a fork. A maintainer needs to add the run-tests label to trigger the clear signing tests.

Once approved, the tests will run automatically and post screenshots here.

@fsamier
fsamier marked this pull request as ready for review August 13, 2026 07:54
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

🔍 ERC-7730 Analysis Complete

📊 0 descriptor(s) analyzed

📥 View workflow run & download reports


✅ Status: ALL CLEAR

No 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

Copy link
Copy Markdown

successfully tested but depends on LedgerHQ/python-erc7730#335

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants