Skip to content

The Escrow tab in SoFi; the wallet's decisions moved into Rust (app lock, pair order, token fields, burn units); biometric removed - #1117

Merged
cryptskii merged 13 commits into
mainfrom
feat/escrow-wallet-trade-tab
Oct 5, 2026
Merged

cryptskii merged 13 commits into
mainfrom
feat/escrow-wallet-trade-tab

Conversation

@cryptskii

@cryptskii cryptskii commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

What this does

The Escrow tab (Trade → SoFi, beside Swap and Liquidity). A party can lock a stake of one token whose outcomes each name who decides them and who they pay (this device or a contact), see their escrow vaults with each outcome's role for this device, sign or decide an outcome they decide, check the verdict, release a stake an outcome pays them, and find the vaults bound to a verdict cell. The copy explains mechanics only: no opponent, referee or wager language (owner, 2026-10-05). escrow.lock is new: Rust supplies every party's genesis and signing key, orders the terms canonically, and refuses a stranger or a repeated name.

The frontend's decisions moved into Rust (owner, 2026-10-05; practice mode excluded):

  • App lock. Rust enrolls the PIN (4–8 digits) or pattern (8 shell presses) as an Argon2id hash, checks every try, stores each miss before answering, and after three misses only the wallet's recovery phrase opens it. No clock. session.unlock no longer opens the session for any caller; a locked wallet cannot change its lock; the preferences route refuses the lock's keys. The lock screen renders Rust's answers.
  • A lock, not a curtain. While the session is locked, Rust answers nothing the app asks but the session's own routes: the ingress refuses every other route and envelope, and the balances read and the bilateral accept/reject/cancel calls refuse too. The receiving loop keeps running, so value still arrives while locked. The lock is on exactly while a PIN or pattern is enrolled; no legacy path remains.
  • Biometric unlock removed (the Game Boy shell never offered it). The unused auto-lock timeout control is gone too: it stored a value nothing read.
  • SoFi pair order: sofi.create_vault takes the two tokens in either order.
  • Token creation: the supply goes as typed; token.check answers what token.create would refuse, field by field, and the wizard keeps no rules of its own. A ticker is counted in characters, as MR-SOFI-0303 states.

Defects found and fixed on the way

  • Burning "5" of a two-decimal token burned 0.05: the frontend parsed the burn amount as base units. token.burn now parses the typed amount at the token's decimals (MR-SOFI-0349).
  • The token wizard showed "Token creation failed" in place of Rust's reason.
  • createToken could not tell Rust's refusal from a call that never came back, so the wizard's ask-again path for an ambiguous outcome never ran. The ingress boundary now throws a RustRefusal for Rust's answers.
  • The preferences-route refusal was tested on its helper only; it is now tested through the ingress.

How it is tested

  • Rust: new tests for every gate above (session manager, ingress, escrow and node e2e, token create, sender admission).
  • Frontend: the real lock screen, token wizard and escrow tab are driven over the bridge, answered from Rust's own records of these routes (ingress.rs writes them; each must equal the live answers). The escrow record comes from a funded device on the network's nodes. No test doubles.
  • Mutations: every gate removed or weakened, a named test watched red, the gate restored: fifteen in Rust, two in the frontend, all red. Recorded in VERIFICATION_MATRIX.md.
  • Local: targeted Rust suites green (ingress, session manager, token create, escrow e2e: 63/0; sender admission and token suites 51/0); frontend 128 suites / 913 tests green; make lint green; the Android JNI changes pass an Android-target check. The full board is CI's.

On the phones

Proven on a real Android device (Samsung A16, beta fleet, fresh wallet): the lock end to end (reads refused while locked, three misses then phrase only, a restart gives no try back, another wallet's phrase refused, this wallet's phrase opens it, cold start locked and the PIN opens it), and an escrow locked, decided and released on one phone (balance back to 100.00 ERA).

Between two phones that are each other's contacts: one locked 5 ERA on two outcomes decided by the other; the other found it by its verdict cell, decided "delivered" and released it, the locker's own release refused; 5 ERA moved. The vault card's buttons were relaid after the labels ran off them on the A16.

Recorded in CONFORMANCE_GAPS.md §6.75 and VERIFICATION_MATRIX.md.

🤖 Generated with Claude Code

…upply

escrow.lock: the wallet names each outcome's signers and payee as this
device or a contact; Rust resolves their keys and genesis, orders the
signers and branches canonically, and refuses a stranger, a repeated
signer or a repeated outcome. escrow.locked / escrow.vaults report each
branch with whether this device decides it and whether it pays this
device.

App lock: the PIN or pattern is enrolled in Rust as an Argon2id hash and
checked there. Each miss is stored before it is answered; after the third
only the wallet's recovery phrase opens it (compared in constant time with
the seed the wallet holds). No clock. session.unlock no longer opens the
session unchecked, a locked wallet cannot change its lock, the lock
settings are stored by Rust when configured, and the preferences route
refuses every key the lock keeps.

Biometric unlock removed: the Game Boy shell offers none. Its proto kinds
are reserved and the Android arm is gone.

sofi.create_vault takes the two tokens in either order and orders them
itself; token.create takes the supply as typed and scales it in Rust.
The frontend no longer hashes, stores or checks the PIN or pattern, and
keeps no attempt count or cooldown. session.unlock answers every try with
Rust's session snapshot: still locked with the tries left, or only the
recovery phrase opens it; the lock screen renders that and offers the
phrase field once Rust requires it. session.configure_lock enrolls the
PIN or pattern in Rust and takes the method alone ("none" turns the lock
off); Rust decides what a lock is (a PIN of 4 to 8 digits, or 8 presses of
the shell's buttons) and stores the settings itself.

The auto-lock-after-inactivity picker is gone: it stored a timeout that
nothing ever read. The settings copy says what the lock does.

The frontend test drives the real lock screen over the bridge, answered
from Rust's own record of the lock (ingress.rs), which must equal the live
answers.
Escrow tab (Trade → SoFi, beside Swap and Liquidity): lock a stake of one
token, its outcomes each naming who decides it and who it pays (this
device or a contact); list this device's escrow vaults with each
outcome's role for this device; sign or decide an outcome this device
decides; check the verdict; release a stake an outcome pays this device;
find the vaults bound to a verdict cell. The copy explains mechanics only.
Rust names every party's keys, orders the terms and answers every state.

token.check answers what token.create would refuse, field by field, and
creates nothing; the creation wizard shows Rust's reasons and keeps no
rules of its own. A ticker is 2 to 8 characters, counted as characters.
The wizard now shows Rust's refusal message on a failed create (it read a
field createToken never set).

token.burn takes the amount as typed and parses it at the token's
committed decimals: the frontend read it as base units, so burning "5" of
a two-decimal token burned 0.05 while the screen said 5.

The frontend tests drive the real wizard and the real escrow tab over the
bridge, answered from Rust's own records (ingress.rs): token.check on a
local device, and escrow on a funded device on the network's nodes. The
record helper answers a request Rust answered more than once in Rust's
order.
…h the ingress

The unit test exercised the refusing helper, not the route: a route that
stopped calling it stayed green. The test now asks prefs.get and prefs.set
for every key the lock keeps as the WebView asks them.
Rust's refusals reach the frontend as thrown errors, and so does a dropped
bridge, so createToken reported both as a failed creation and the wizard's
ask-again path for an ambiguous outcome never ran: a token created while
the call was lost was shown as not created. The ingress boundary now
throws a RustRefusal for Rust's answer; createToken returns that as a
creation that did not succeed and rethrows anything else, so the wizard
asks again with the identical request.

Tested over the bridge against Rust's own record of a refused token.create
(the token wizard's record gains it); each of the two mutations of the
distinction turns one of the two tests red.
The lock covered the screen only: with the session locked the app could
still call every route, read balances and accept a bilateral transfer.
While the session is locked the ingress now refuses every route and
envelope but the session's own and the receiving loop the host keeps
going, so value still arrives; the JNI calls the WebView reaches directly
(balances, bilateral accept, reject and cancel) refuse too. Once the lock
opens, the lock screen has the wallet and the contacts read again.

The lock is on exactly while a PIN or pattern is enrolled: the separate
"enabled" setting, the path for a device whose lock the old frontend set,
and its test are gone, and the method is stored with the credential. The
proto reservations and removal notes this branch added are gone too.

Recorded: CONFORMANCE_GAPS §6.75 (S-LOCK resolved; MR-SOFI-0303 and
MR-SOFI-0349 rows), and VERIFICATION_MATRIX rows for every gate of the
branch with its mutation: fifteen in Rust and two in the frontend, each
red, each restored.
…rded

Run on a Samsung A16 on the beta fleet: the lock end to end (PIN, misses,
phrase, refusals while locked, restart, cold start) and an escrow locked,
decided and released on one phone (CONFORMANCE_GAPS §6.75).
@cryptskii
cryptskii marked this pull request as ready for review October 5, 2026 15:28
…r outcome

With three actions in one row the labels ran off the buttons on the A16.
The two-phone escrow run is recorded in CONFORMANCE_GAPS §6.75.
What it was granted, its account, the requests handled and what this
wallet did for it open from the card's header. The approval screen still
shows the grant in full; it is where it is read.
CodeQL (rust/cleartext-logging, 3 high) flagged the assertion messages,
which named the refused secret; they now name the refusal expected.
The SDK's node harness runs the five pinned nodes on one Postgres server,
and each node's pool could open db::POOL_MAX_SIZE = 32 connections: 160
against the 100 CI's postgres:16 service admits. Under the bursts #1111's
HTTP/2 and parallel reads produce, Postgres refused connections ("sorry, too
many clients already"), the node answered /bytecommit/mirror and
/bytecommit/proof with 500, and a reader held fewer verified links than the
cell had. A claim final at every seat then read as "not final yet".

That is the failure one_resolution_asks_each_node_for_a_final_cell_or_an_
object_once has shown in 7 CI runs since #1111 (B's sofi.route: "vault owner
lineage: position N: the claim holding the root cell is not final yet",
N = 1, 3, 4, 5, 6), and token_routes_admit_create_and_burn_end_to_end once
on the writer's own read ("the claim holds the cell, Preserved").

Established by execution:
- When create_vault returns, A holds no pending admission, and every one of
  A's root cells (positions 1-8) reads Final with links at all five seats,
  to A's settlement read and to B's sequential and concurrent reads. The
  SDK does not return before the owner's claim is final, and the test's
  ordering is already the one it depends on.
- On a private server with max_connections = 300 the test passed 40+ runs
  (alone, the whole node_e2e module, and under CPU stress). At 100 the
  Postgres log shows the refusals and the 500s land on the evidence
  requests; at 50 even the boot fails.
- Production is unaffected: each deployed node has a sibling Postgres of
  its own (deploy/docker-compose.node.yml), which grants all 32.

The fix:
- db::create_pool takes the pool's size from its caller. The node binary
  and the node's own tests pass POOL_MAX_SIZE, unchanged.
- NodeSet::start gives each node an equal share of what the server admits
  beyond other clients' connections (max_connections less the reserved
  slots), capped at POOL_MAX_SIZE: 19 per node at 100. The harness's admin
  pools hold one connection.
- test_support::nodes_tests::the_shared_server_grants_every_connection_a_
  node_set_may_hold holds every connection each pool may hold at once, and
  checks no pool opens one past them. It sits beside nodes.rs, not in it:
  dsm-app-host's real-connection suite mounts nodes.rs by #[path] (and gets
  the same sized pools). Pools of 32 fail it on the refused connection; a
  pool built at 32 and then resize()d fails it too, since deadpool's resize
  on an empty pool forgets no permits and the pool keeps opening
  connections past its size.
Evidence: CI's board at bb517e1 (dsm, dsm_sdk, workspace-rest, storage
node job logs, 3,097 tests ok); the two storage-node binaries the job log
interleaved re-run locally at the same commit. requirement-map-intent over
CI's map: 0 failing rows, 0 failing pins.
@cryptskii
cryptskii merged commit 441b16b into main Oct 5, 2026
30 of 46 checks passed
@cryptskii
cryptskii deleted the feat/escrow-wallet-trade-tab branch October 5, 2026 20:38
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