Skip to content

feat(sofi): port the hardware-proven trader swap + owner reconcile fixes to main - #673

Merged
cryptskii merged 6 commits into
mainfrom
feat/sofi-port-trader-swap-and-owner-reconcile-to-main
Aug 19, 2026
Merged

cryptskii merged 6 commits into
mainfrom
feat/sofi-port-trader-swap-and-owner-reconcile-to-main

Conversation

@cryptskii

@cryptskii cryptskii commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator

Why

The SoFi trader path on main has been broken since 2026-08-01: the four fixes that made funded creation → trader settlement work on silicon (TRACE-2026-08-01-062/063) were committed to chore/bench-inspectable-release-build and never merged. On main today: the SWAP brick is dead-routed, SwapTab names the pair by label bytes (no liquidity discoverable), route_commit.ts publishes X without the pending pointer (settle refused "slot not held"), and nothing calls dlv.reconcile — the owner cannot fold settled trades. Surfaced while staging the multi-generation LP-offline hardware proof for #670/#672.

What lands (original commits, original authorship)

  • 0bf28f3e fix(sofi): the SWAP brick could never open — 'swap' added to VALID_NAV_TARGETS.
  • 6979b35c fix(sofi): the trader named the pair with the bytes of a label — SwapTab sends 32-byte CPTA anchors.
  • d0b707d1 fix(sofi): a settlement pointer is required, and Rust holds the route — Rust retains the signed RouteCommit keyed by its own X; route.publishExternalCommitment takes an anchor only and publishes X + RC + the mandatory sofi/vault-pending/{vault}/{seq}/{X} pointer, refusing if any hop is unpublished. PublishExternalCommitmentRequest deleted.
  • fc7659e5 fix(sofi): the owner's vaults come from persisted state, not a cache — ported by hand (conflicted): dlv.listOwnedAmmVaults reads amm_vault_records + the head's reserve leaves via rehydrate_amm_vault, never the in-memory DLVManager, so an LP that force-stops and relaunches still sees its vault (the restart-persistence bug from finding_rehydration_never_called_restart_probe). Absence is never rendered as zero.
  • a0485443 feat(sofi): the owner can see settled trades, and fold them — ported by hand (conflicted with feat(dlv): enforce consume-once per vault generation + prove reserve authority #670/feat(dlv): settle against the composed vault state — delegated liquidity across generations #672's reshaped dlv_routes.rs): dlv.listOwnedAmmVaults reports the real pending_unapplied + pending_x (was hardcoded 0); LiquidityScreen shows the count and a Reconcile action that folds in order and stops at the first failure.

Port deltas vs the originals:

  • AmmVaultSummaryV1.pending_x is field 19 (main assigned 17/18 to the ticker labels after Aug 1); TS proto regenerated.
  • The pending list is explicitly ordered by each receipt's new_sequence. Since feat(dlv): enforce consume-once per vault generation + prove reserve authority #670 a fold consumes exactly the current parent, so generation N must fold before N+1; the storage key layout happens to sort that way, but fold order is a protocol requirement, not a property of a path string.

Gates

route_routes 7/7, vault_rehydration 11/11, dlv_routes 15/15 (serial); frontend 82/82 on the touched suites; make lint clean (fmt, clippy --all-targets -D warnings, npm lint — the one npm warning is pre-existing on main in RecoveryPipelineScreen.tsx); proto guards + codegen enforce pass; no_clock_and_no_json + production_safety_checks (prod clippy + TLA+) pass.

Prerequisite for the multi-generation LP-offline hardware proof (owner directive 2026-08-18).

On a handset the SoFi hub's SWAP tile did nothing. The click fired, the handler
ran, no error appeared in the console or logcat, and the screen did not change.
LIQUIDITY and MAIL from the same hub worked.

`'swap'` was missing from `VALID_NAV_TARGETS`. `navigate` drops an unknown
target with a bare `return`, so the request vanished silently. Everything else
about the path was correct and had been all along: `'swap'` is a member of the
`ScreenType` union, `AppScreenRouter` has handled `case 'swap'` for as long as
the hub has existed, and `SofiHubScreen` declares it as a brick target. The
compiler saw a legal target; the runtime Set refused it; nothing reported the
disagreement.

This was the trader's only entrance to the production swap surface, which is
why it went unnoticed - every swap exercised so far went through the dev screens
or direct route calls.

The test walks the hub's three brick targets and requires each to actually land.
A type union and a runtime allowlist that are not derived from one another will
drift again; this pins the three that a user can press. Verified non-vacuous:
removing `'swap'` fails it.
`SwapTab` sent `new TextEncoder().encode(inputToken.trim())` as the pair
identity. A vault's pair is two 32-byte CPTA policy commits, so the trader was
asking for a market named by the UTF-8 bytes of whatever was typed. Those two
can never be equal. `syncVaultsForPair` and `listAdvertisementsForPair` matched
nothing, every quote ended in "No liquidity advertised", and no advertised vault
was reachable from the production swap surface at all.

This is the same defect that was fixed on the owner's side — LiquidityScreen
stopped naming pairs by label when the picker moved to policy commits — and the
trader's half of that pair was left behind. Discovering it needed both halves
running against one real vault: a funded vault on one handset and a trader on
another, which is the first time the two sides were exercised together.

Both fields now decode Base32 Crockford to exactly 32 bytes and refuse anything
else, naming the field that was wrong. There is no ticker path and no fallback:
a label can name more than one token — two distinct tokens have shared the
ticker RIGB in this repo — so resolving one here could name a different asset
than the trader meant while every signature downstream still verified.

The datalist now suggests the ANCHORS of held tokens, labelled with their
ticker, because the anchor is what the field means. Suggesting tickers invited
precisely the input the field cannot accept. An anchor for a token you do not
hold is still accepted by hand — you do not hold what you are buying.

The tests typed 'DEMO_AAA' and 'ERA' into those fields, so they encoded the
broken contract and passed under it. They now use real 52-character anchors.
The trader anchored a commitment, the route reported success, and settlement
then failed with "settlement slot not held: this trader's pending pointer is
not visible in the slot". Storage said why: `sofi/vault-pending/` was EMPTY at
every sequence, while the vault's advertisement sat next to it under
`sofi/vault/`. No pointer had ever been written, for any vault.

`route.publishExternalCommitment` accepted two shapes: a bare
`ExternalCommitmentV1`, or a wrapper carrying the signed RouteCommit so it could
derive the per-hop pending pointers. With no RouteCommit it published the X
anchor ALONE and returned success. The frontend sent the bare shape, so that
branch was the live path on every trade.

Two things were wrong and only one of them was the missing pointer.

The signed RouteCommit was expected to travel out to the caller and back. It is
protocol state - hops, vault ids, sequences, the bound amounts - and it has no
business in the render layer. `route.signRouteCommit` now retains it against its
own X, and `route.publishExternalCommitment` resolves it there. The caller sends
an anchor and nothing else; the shape it cannot get wrong is the one it no
longer supplies. `PublishExternalCommitmentRequest` is deleted, not deprecated.

Pointer publication is mandatory. An X with no retained route is refused, a
route with no hops is refused, and a run where any hop fails to publish returns
an error naming how many of how many failed. It used to log a warning at
`log::warn!` and return OK, so the only way to discover a missing pointer was to
reach the settlement gate and be turned away - which is precisely what happened
on hardware, one screen and several minutes after the moment that could still
have been retried.

The producer and the consumer are now tested against one route and one slot:
sign through the router, publish, read the pointer back out of storage, assert
the key names that vault, that sequence and that trade, then drive the real
`claim_settlement_slot` and require it to see exactly that pointer. Nothing
covered both halves before, which is how they were free to disagree. Verified
non-vacuous: suppressing the pointer write fails it.
…o main)

Port of a0485443 from chore/bench-inspectable-release-build (hardware-proven
2026-08-01, never merged). `dlv.listOwnedAmmVaults` now reports the REAL
number of settlements this owner has not folded (`pending_unapplied`, was
hardcoded 0 under a comment saying reconciliation was not wired) plus each
one's external commitment (`pending_x`), read from storage against this
head's own reserve leaves; the LiquidityScreen shows the count and a
Reconcile action that folds them in order and stops at the first failure.

Port notes vs the original:
- `AmmVaultSummaryV1.pending_x` is field 19, not 17 — main assigned 17/18 to
  the ticker labels after Aug 1. TS proto regenerated to match.
- The pending list is now EXPLICITLY ordered by each receipt's `new_sequence`
  before it is returned. Since #670 a fold consumes exactly the current parent
  (the reserve-leaf `parent_sequence` claim), so generation N must fold before
  N+1; the storage key layout happens to sort that way, but fold order is a
  protocol requirement, not a property of a path string.
- Placed in main's reshaped summary loop (ticker resolution + policy-digest
  republish fields), computed after the vault lock is dropped.
…(ported to main)

Port of fc7659e5 from chore/bench-inspectable-release-build (hardware-proven
2026-08-01, never merged). `dlv.listOwnedAmmVaults` enumerated the in-memory
DLVManager, which holds only what the current process created — so a wallet
that had merely been restarted showed the owner "My vaults (0)" over a funded,
published vault. `rehydrate_all_amm_vaults` was correct, tested, and had zero
production callers.

The route now reads the authoritative pair: the `amm_vault_records` row for
identity and policy, the head's encumbered reserve leaves for reserves and
sequence, joined through `rehydrate_amm_vault`. A record that is non-canonical,
names another owner, carries an unknown enforcement mode, or whose legs are
absent or disagree makes that vault UNAVAILABLE — absence is never rendered as
zero. Repopulating the manager is rejected deliberately (the record does not
carry parameters_hash / creator_signature / encrypted_content; synthesising
them would place a complete-looking object nobody computed in front of every
consumer).

Port notes: main's post-Aug-1 additions are carried into the persisted-state
loop — ticker display labels (resolved from the commit; identity is never the
label) and the ordered `pending_x` / real `pending_unapplied` from the previous
commit. Prerequisite for the LP-offline hardware proof: an LP that force-stops
and relaunches must still see its vault to fold the market's settlements.
@cryptskii
cryptskii merged commit bf2e006 into main Aug 19, 2026
26 of 28 checks passed
@cryptskii
cryptskii deleted the feat/sofi-port-trader-swap-and-owner-reconcile-to-main branch August 19, 2026 04:09
cryptskii added a commit to cryptskii/dsm that referenced this pull request Sep 10, 2026
… proof

Hardware-proved 2026-08-18 on the 3-phone rig against the live Alibaba fleet
(schema v4, APK carrying deterministicstatemachine#670 + deterministicstatemachine#672 + the deterministicstatemachine#673 SoFi UI ports):

  LP (8XK) created SOFI (CPTA), funded a Required SOFI/ERA vault
  (25,000,000 / 100, 30 bps) — head: SOFI 100,000,000 -> 75,000,000,
  ERA 290 -> 190, exactly the two reserve leaves at gen 0 — then was
  force-stopped. With the LP process dead, T1 (9FF) and T2 (5GN) settled
  four generations through the production SwapTab (0->1 T1 20 ERA -> 4,156,244;
  1->2 T2 15 ERA -> 2,309,794; 2->3 T1 10 -> 1,274,633; 3->4 T1 8 -> 899,882),
  every quote exact against the COMPOSED reserves the trader derived from the
  prior receipts alone. T2 then confirmed a quote bound to gen 3 after T1 had
  consumed it: refused by the delegation guard ("is at generation 4 but the
  route binds generation 3 — that parent is already consumed"), T2's root
  byte-identical, one unreceipted pointer left on the fleet and nothing else.
  The LP relaunched, saw its vault (persisted-state listing) with "4 settled
  trades to reconcile", tapped Reconcile: leaves at gen 4, reserves
  16,359,447 / 153 == funding + Σ inputs − Σ outputs, consumption rows 0..3
  by four distinct receipts, LP spendable untouched (no second debit).

scripts:
- rig_dlv_market.py      — step-wise CDP driver over the PRODUCTION UI (create-token,
                           anchors, create-vault, vaults, swap [quote|execute|full],
                           reconcile, balances, offline/online). CDP substitutes for
                           finger taps only; every protocol step is the real route.
- dsm_head_decode.py     — host-side DeviceState v0x06 decoder for a pulled
                           dsm_client.db: balances + reserve leaves, legs named via
                           amm_vault_records + BLAKE3 leaf keys (b3sum). No hex.
- rig_settle_foreground.py — clears Android permission dialogs + the lock prompt so
                           the WebView owns the foreground (fresh-onboarding trap).
- dlv_market_rig_proof.sh — read-only proof over the three pulled DBs + the fleet's
                           public object listings; 17 assertions, all PASS.
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.

1 participant