Skip to content

"Dock position" never sends a transaction — the position stays live on-chain #53

Description

@GustavoSena

"Dock position" never sends a transaction — it flips a local flag and lies about it.

The dashboard's dock action is a UI-only overlay. Nothing reaches Aqua, no wallet prompt opens, the strategy stays live on-chain and stays fillable by any taker — while the screen tells the user, in the strongest wording it has anywhere, that the position is permanently closed.

What happens today

useBook().dock is the whole implementation (packages/app/src/lib/book.tsx:301):

const dock = useCallback((id: string) => {
  setDockOverrides((prev) => ({ ...prev, [id]: Math.floor(Date.now() / 1000) }));
}, []);

dockOverrides is a Record<id, unixSeconds> merged over the joined book in positions (book.tsx:328-333). It was deliberate at the time — the comments say so:

Local optimistic dock — real dock() is out of scope; refetch resets it. (book.tsx:125)
Local-only dock overlay; a real dock is out of scope for this build (Wiring §10) (book.tsx:190)

That was fine as a placeholder. It is no longer fine as shipped behaviour, because of what the UI promises around it.

Why this is a safety bug, not a cosmetic one

The detail sheet arms the button into "Confirm dock — permanent" and puts this next to it (detail-sheet.tsx:213-229):

A docked strategy can never be re-shipped — only recreated from scratch.

The user confirms an action described as irreversible, the sheet closes, the card renders docked <date> (dashboard.tsx:215), and the summary line counts it under "docked". Every signal says the commitment is closed.

None of it is true. strategyHash is still live on (maker, app, hash), its virtual balance is still committed, and the next taker to route through the SwapVM router can still pull against it. The user has been told their exposure ended when it did not — and CLAUDE.md is explicit that dock() is the exit: "an Aqua strategy is immutable once shipped — the only exit is dock() (or a _deadline unwind)". The one action that ends a position is the one that does nothing.

And it silently reverts

Every book read clears the overlay — book.tsx:241 calls setDockOverrides({}) unconditionally inside the fetch effect, which runs on mount, on refetch(), and on reconnect. So the "permanent" dock survives until the next reload, then the position reappears as Live with no explanation. Inconsistent in both directions: it lies while it holds, and it lies again when it drops.

What a real dock needs

The call is on Aqua itself, signature confirmed against the fork test (contracts/test/StrategyHashSemantics.t.sol:18):

function dock(address app, bytes32 strategyHash, address[] calldata tokens) external;
  • app = SWAPVM_ROUTER, strategyHash = position.strategyHash.
  • The maker is msg.sender. The user signs it from their own wallet — no contract of ours in the path, same constraint that forced ship() through Aqua's own multicall rather than Multicall3 (ship.ts:20).
  • The burn is per (maker, app, hash) — one maker docking cannot touch another's row holding identical bytes (StrategyHashSemantics.t.sol:150-154).
  • AQUA_ABI in packages/app/src/lib/aqua.ts carries only ship and multicalldock has to be added.
  • The fork guard applies: assertVenue (ship.ts:38) probes anvil_nodeInfo and refuses to sign on real Base without NEXT_PUBLIC_ALLOW_MAINNET. A dock is a signing path and must go through the same gate — otherwise it becomes the one way to send a live mainnet transaction from the app.

The trap: the token list must be complete

tokens is caller-supplied, and an incomplete list is worse than no dock at all. Our own subgraph treats Docked as terminal — subgraph/src/aqua.ts:72 sets status = "DOCKED", zeroes every StrategyBalance it knows about and decrements the book counters, and the later handlers early-return on status == "DOCKED" with the comment:

only reachable after an empty/partial dock (the strategy is still live on-chain); the index closed it and removed it from the book, so keep its later events out too (aqua.ts:95, aqua.ts:132)

So a dock that omits a token the strategy holds leaves it live and fillable on-chain while permanently invisible in the book — the exact failure this issue is about, but unrecoverable. Pass every token: position.legs.map((l) => l.token).

Suggested approach

  1. dockPosition() in packages/app/src/lib/ship.ts (or a sibling dock.ts): assertVenuewriteContract the dock(app, hash, tokens)waitForTransactionReceipt. Add dock to AQUA_ABI.
  2. Wire the sheet's confirm to it with real pending / rejected / failed states. Today onDock closes the sheet immediately (dashboard.tsx:92) — with a signature in the path there is a wallet prompt to wait on and a rejection to survive without the card having already flipped.
  3. Keep an optimistic overlay, but set it only after the receipt confirms, then refetch() — mirroring what compose-screen.tsx does after a ship (compose-screen.tsx:245). Dropping the overlay entirely would make the position pop back to Live until the subgraph indexes the Docked event. Note setDockOverrides({}) at book.tsx:241 would then wipe a just-confirmed dock on the very refetch that follows it — the reset needs the same account-switch-only treatment optimistic already gets.
  4. Docked rows already read correctly once indexed: consumed is computed from net pulls precisely because the dock refund zeroes the balance (position-from-subgraph.ts:177). No change needed there.

Open questions

  • Expired positions. The button shows for anything not already Docked (detail-sheet.tsx:221), which reads right — after a _deadline unwind the committed balance is still held, and docking is how it comes back. Needs confirming against Aqua that a dock past the deadline is accepted rather than reverting.
  • Interim honesty. If a real dock cannot land before the demo, the placeholder must stop claiming permanence: relabel the button and drop "can never be re-shipped". A disabled button beats a false confirmation.
  • Batching. One dock per position is fine; folding several into one EIP-5792 batch is a nicety, not a requirement.

Acceptance criteria

  • Confirming a dock opens a wallet prompt and sends Aqua.dock(SWAPVM_ROUTER, strategyHash, tokens) from the user's own account.
  • The token list covers every token in the strategy's legs — a partial dock is impossible to trigger from the UI.
  • The dock path runs the assertVenue fork probe; on real Base without NEXT_PUBLIC_ALLOW_MAINNET it refuses to sign, with the same message shape as ship.
  • A rejected or reverted dock leaves the position rendered as Live, with the failure surfaced — never as Docked.
  • After a confirmed receipt the position reads Docked and stays Docked across a refetch and a page reload (overlay until the subgraph catches up, then the indexed row).
  • Fork rehearsal end to end: ship → dock → the strategy no longer fills for a taker (Take.s.sol against the docked hash fails or draws nothing), and the subgraph reports status: DOCKED with dockedTx set.
  • No dockOverrides entry can exist for a position that was never docked on-chain.

Context: F1 — Aqua & the Strategy VM / Wiring §10. Surfaced reading the dashboard dock path; book.tsx's own comments flag it as out of scope for the previous build, so this is the follow-up that closes it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions