Skip to content

fix(route): dlv.unlockRouted settles every hop the signed route names; the wallet stops picking a vault - #884

Merged
cryptskii merged 1 commit into
mainfrom
fix/unlock-routed-executes-every-hop-in-rust
Sep 14, 2026
Merged

cryptskii merged 1 commit into
mainfrom
fix/unlock-routed-executes-every-hop-in-rust

Conversation

@cryptskii

Copy link
Copy Markdown
Collaborator

What broke on hardware (2026-09-14 run)

After vault 3ZAWV9A1… (ERA/SOFI) was closed and 7PN5EWW9… created for the same pair, a trader's single-hop trade bound to the live vault, signed, published its external commitment and pending pointer, then died at dlv.unlockRouted: route-commit eligibility rejected: VaultNotInRoute. SwapTab.tsx unlocked vaults[0] of listAdvertisementsForPair (the closed vault) instead of the vault in its own signed route. It also issued exactly one unlock, so a multi-hop route (ERA → SOFI → NOVA across two pairs, which the binder does produce) could never execute from the UI. Vault choice was business logic in the frontend.

Fix (owner ruling: Rust executes the signed route)

  • dlv.unlockRouted accepts an empty vault_id: Rust decodes the RouteCommitV1, walks hops in order, and settles each through the existing per-hop path, extracted verbatim as unlock_routed_hop. The first refusal stops the walk and names the hop (hop k of n (vault …) refused: …); a 32-byte vault_id keeps the one-hop contract for owner-side callers. Response is the last settled hop's status:b.
  • unlockVaultRouted (frontend): vaultId optional; empty on the wire when omitted.
  • SwapTab: no primaryVaultId, no vaults[0]; unlock passes only the signed route; the quote renders the hops the binder committed.

Verification

  • New test an_empty_vault_id_settles_the_hop_the_signed_route_names (dsm_sdk lib, release): the route's own hop settles with no caller-named vault; trader debited exactly the hop input and credited exactly its output; the named vault is at generation 1 with the traded reserves. Mutation control: refusing the empty path → 0 passed; 1 failed; restored.
  • Existing explicit-vault path: closing_a_traded_vault_returns_exactly_the_leaf_reserves_and_kills_the_vault still green.
  • cargo fmt --check clean. Frontend: npm run type-check exit 0; route_commit.test.ts + SwapTab.test.tsx 48/48; eslint clean on both files.
  • Hardware: this branch is being built and installed on the four phones to finish the double-LP run (vault 2 SOFI/NOVA, then ERA → SOFI → NOVA). Results follow in the run sheet.

Multi-vault split routing (one order across several vaults of one pair) is a separate, unimplemented feature and is not touched here.

… — the wallet stops picking a vault

On the 2026-09-14 four-phone run, a pair with two advertisements (a closed
vault and its replacement) made a single-hop trade fail at
`dlv.unlockRouted: route-commit eligibility rejected: VaultNotInRoute`: the
route was bound to the live vault, but SwapTab unlocked `vaults[0]` of the
advertisement list. The same single call also meant a multi-hop route could
never be executed from the UI.

Which vaults settle is the signed RouteCommitV1's decision. `dlv.unlockRouted`
now accepts an EMPTY `vault_id`: Rust decodes the route and settles every hop
in hop order through the existing per-hop path (now `unlock_routed_hop`),
stopping at the first refusal and naming the hop. A 32-byte `vault_id` keeps
the one-hop contract for owner-side callers. SwapTab passes only the signed
route and renders the hops the binder committed; `primaryVaultId` is gone.

Test: `an_empty_vault_id_settles_the_hop_the_signed_route_names` — the
route's own hop settles with no caller-named vault, exact debit/credit on the
trader, the named vault is the one that moved. Mutation control: refusing the
empty path turns it red. The explicit-vault path is covered by the existing
close test, still green. Frontend: type-check clean, 48/48 in the two suites,
eslint clean.
(10_000, 5_000),
1_000,
out,
0x21,
@cryptskii
cryptskii merged commit ad4e450 into main Sep 14, 2026
20 of 22 checks passed
@cryptskii
cryptskii deleted the fix/unlock-routed-executes-every-hop-in-rust branch September 14, 2026 07:46
cryptskii added a commit that referenced this pull request Sep 14, 2026
…s are refused before any bind, the binder searches one hop deep (#885)

* fix(route): beta settles one vault transition per route; a route naming more hops is refused before any bind

`dlv.unlockRouted` decodes the signed route once and refuses a route naming
more hops than the beta profile settles — in both addressing modes, an empty
`vault_id` and a named vault — before any composition, publication, trader
fence, quorum binding, reserve movement or receipt. The one hop a beta route
may carry is then settled through the route-authoritative path (#884). The
per-hop loop #884 introduced is removed: SoFi V2 §16.2 forbids emulating route
atomicity by completing separate settlement decisions per DLV, §9.5 calls a
per-leg verify-then-bind loop non-conforming, and the conforming form (one
bundle carrying every T_v, one QuorumBind over the complete K(B), one trader
advance — Def 6.14, Req 6.18, Thm 18.3) is fenced out of beta by amendment
2c-A ruling 3 (`BETA_TRANSITIONS = 1`).

New `sdk::sofi_profile`: `BETA_MAX_HOPS`, `BETA_MAX_TRANSITIONS`,
`BETA_MAX_FANOUT` — sequential depth, transitions per bundle and DLVs per
same-pair leg are three dimensions of the routing model, stated separately and
never aliased, so raising one later never raises another by accident; a test
pins the transition limit to the core's cardinality. `bounded_search_depth` is
the pure clamp the binder uses. A const assertion in `dlv_routes` marks that
this path settles one hop and that a deeper profile is the route-wide bundle.

Tests: a hostile caller hand-builds and signs a two-hop route, publishes X,
and is refused with zero fleet puts, zero CAS rounds, no trader fence, no
balance or generation change (baseline taken after X); the profile triple;
the clamp; the transition-limit agreement; the single-hop route test stays
green.

* fix(route): the binder searches no deeper than beta can settle, and mirrors the hop vault it chose

`route.findAndBindBestPath` bounds its search depth with
`sofi_profile::bounded_search_depth` (a larger request is clamped and logged,
not refused), so the wallet never signs, and never publishes X for, a route
`dlv.unlockRouted` refuses. After the path is chosen the binder mirrors the
hop's vault into the local DLVManager (`mirror_advertised_vault`, extracted
from the sync handler, which now uses it too), so the unlock finds it without
the UI syncing a pair.

SwapTab no longer syncs or lists the pair and no longer pre-checks direct
liquidity: it renders the binder's hops ("1 hop bound · vault …") and passes
only the signed route to the unlock. Discovery and execution are Rust's; the
wallet requests and renders.

Test: a → c across advertised a/b and b/c is refused at max_hops 0 and 99
(pair-scoped discovery, nothing deeper searched), a → b binds one hop and its
vault is mirrored. Frontend suites 48/48, type-check and lint clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants