fix(route): dlv.unlockRouted settles every hop the signed route names; the wallet stops picking a vault - #884
Merged
Conversation
… — 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.
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.
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.
What broke on hardware (2026-09-14 run)
After vault
3ZAWV9A1…(ERA/SOFI) was closed and7PN5EWW9…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 atdlv.unlockRouted: route-commit eligibility rejected: VaultNotInRoute.SwapTab.tsxunlockedvaults[0]oflistAdvertisementsForPair(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.unlockRoutedaccepts an emptyvault_id: Rust decodes theRouteCommitV1, walkshopsin order, and settles each through the existing per-hop path, extracted verbatim asunlock_routed_hop. The first refusal stops the walk and names the hop (hop k of n (vault …) refused: …); a 32-bytevault_idkeeps the one-hop contract for owner-side callers. Response is the last settled hop'sstatus:b.unlockVaultRouted(frontend):vaultIdoptional; empty on the wire when omitted.SwapTab: noprimaryVaultId, novaults[0]; unlock passes only the signed route; the quote renders the hops the binder committed.Verification
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.closing_a_traded_vault_returns_exactly_the_leaf_reserves_and_kills_the_vaultstill green.cargo fmt --checkclean. Frontend:npm run type-checkexit 0;route_commit.test.ts+SwapTab.test.tsx48/48; eslint clean on both files.Multi-vault split routing (one order across several vaults of one pair) is a separate, unimplemented feature and is not touched here.