"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 multicall — dock 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
dockPosition() in packages/app/src/lib/ship.ts (or a sibling dock.ts): assertVenue → writeContract the dock(app, hash, tokens) → waitForTransactionReceipt. Add dock to AQUA_ABI.
- 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.
- 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.
- 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
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.
"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().dockis the whole implementation (packages/app/src/lib/book.tsx:301):dockOverridesis aRecord<id, unixSeconds>merged over the joined book inpositions(book.tsx:328-333). It was deliberate at the time — the comments say so: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):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.
strategyHashis 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 — andCLAUDE.mdis explicit thatdock()is the exit: "an Aqua strategy is immutable once shipped — the only exit isdock()(or a_deadlineunwind)". 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:241callssetDockOverrides({})unconditionally inside the fetch effect, which runs on mount, onrefetch(), 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):app=SWAPVM_ROUTER,strategyHash=position.strategyHash.msg.sender. The user signs it from their own wallet — no contract of ours in the path, same constraint that forcedship()through Aqua's ownmulticallrather than Multicall3 (ship.ts:20).(maker, app, hash)— one maker docking cannot touch another's row holding identical bytes (StrategyHashSemantics.t.sol:150-154).AQUA_ABIinpackages/app/src/lib/aqua.tscarries onlyshipandmulticall—dockhas to be added.assertVenue(ship.ts:38) probesanvil_nodeInfoand refuses to sign on real Base withoutNEXT_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
tokensis caller-supplied, and an incomplete list is worse than no dock at all. Our own subgraph treatsDockedas terminal —subgraph/src/aqua.ts:72setsstatus = "DOCKED", zeroes everyStrategyBalanceit knows about and decrements the book counters, and the later handlers early-return onstatus == "DOCKED"with the comment: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
dockPosition()inpackages/app/src/lib/ship.ts(or a siblingdock.ts):assertVenue→writeContractthedock(app, hash, tokens)→waitForTransactionReceipt. AdddocktoAQUA_ABI.onDockcloses 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.refetch()— mirroring whatcompose-screen.tsxdoes after a ship (compose-screen.tsx:245). Dropping the overlay entirely would make the position pop back to Live until the subgraph indexes theDockedevent. NotesetDockOverrides({})atbook.tsx:241would then wipe a just-confirmed dock on the very refetch that follows it — the reset needs the same account-switch-only treatmentoptimisticalready gets.consumedis computed from net pulls precisely because the dock refund zeroes the balance (position-from-subgraph.ts:177). No change needed there.Open questions
detail-sheet.tsx:221), which reads right — after a_deadlineunwind 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.dockper position is fine; folding several into one EIP-5792 batch is a nicety, not a requirement.Acceptance criteria
Aqua.dock(SWAPVM_ROUTER, strategyHash, tokens)from the user's own account.assertVenuefork probe; on real Base withoutNEXT_PUBLIC_ALLOW_MAINNETit refuses to sign, with the same message shape as ship.Take.s.solagainst the docked hash fails or draws nothing), and the subgraph reportsstatus: DOCKEDwithdockedTxset.dockOverridesentry 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.