Skip to content

feat: the typed-surfaces arc, flavors, fail-close, and the convergence-proof upgrades - #1243

Open
zancas wants to merge 12 commits into
cadence_retirementfrom
typed_surfaces
Open

feat: the typed-surfaces arc, flavors, fail-close, and the convergence-proof upgrades#1243
zancas wants to merge 12 commits into
cadence_retirementfrom
typed_surfaces

Conversation

@zancas

@zancas zancas commented Jul 29, 2026

Copy link
Copy Markdown
Member

Second arc of the nym_linear split, stacked on #1242 (open with that branch as base; retarget to dev when it merges). Twelve commits cherry-picked from nym_linear, selected by one filter: work that stays relevant under the ADR 0024 mixnet convergence (zingolib#2586 and the phase plan in zingolib#2578 / zingo-mobile#1236), and that lands under a net 2000-line diff. This PR is net +1625 (+2107 −482).

What's in, and why it survives the convergence:

  • The Always On flavors and the fail-closed gate (alwayson, alwaysontest, mixnetGate). The silent-alpha build infrastructure is mobile-only and untouched by the convergence; the fail-closed covered-surface gate is the direction zingo-mobile#1235 demands.
  • The typed Mixnet error variant across the Swift and TS bridges. Three lines, and exactly the "consumers match typed variants, never prose" rule the convergence ratifies.
  • Typed price-fetch outcomes (ADR 0004) with the exhaustive handler record, plus the FFI error-chain fix and the oracle TLS fix. The convergence keeps price behind the mixnet; typed outcomes are how its driver wants to be consumed.
  • The null-audit unions (four ambiguous nulls become named discriminated unions), the single-definition selection and FFI-decode patterns, and the three-tool dead-code sweep, with the coordinator poll-cadence re-export the dev-era test needs.

Deliberately excluded, staying with the price-engine arc: the mixnet route attestation and the 25-second typed timeout. Both assume the three-source race and the lock-free price flow (ADR 0005), which zingolib#2586's socks5-fetch gating reshapes; carving them out here would have meant importing half of that machinery. Also excluded: everything the convergence slates for deletion (MixnetCoordinator internals, mixnet_mode_string, the substring markers) — per the handoff, only tactical fixes touch that code.

Verified locally: cargo check clean, tsc clean, 87 tests across the 11 touched-area suites pass. The pre-existing local snapshot failures reproduce identically on the dev tip and are a worktree-symlink artifact (asset testUri paths), not drift.

🤖 Generated with Claude Code

zancas and others added 12 commits July 28, 2026 22:55
The alwayson flavor produces the silent alpha APK (CONTEXT.md): the
forced-on, fail-closed Mixnet Mode policy runs unchanged, but the app
withholds the mixnet view projection, so the stock UI renders and a
refusal surfaces as a plain typed send error.

The flag rides a per-flavor res bool, following the
enforce_privacy_controls precedent, because BuildConfig generation is
deliberately disabled for build reproducibility. NymTransportModule
exports it as the mixnetAlwaysOn constant, and LoadedApp gates exactly
two seams on it: the initial context state (null, never the
send-blocking INITIAL_MIXNET_VIEW) and the onMixnetViewChanged
callback. Every mixnet surface already renders nothing for a null view,
so no component changes. A unit test pins the gate across every
native-module shape, including the module's absence on iOS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zingolib pin advanced to a ZingolibError that adds a Mixnet variant
between Read and Send, but only the generated bindings learned of it.
Swift classifies by a hand-written switch that carries no default,
deliberately so a new variant breaks the build rather than degrading
silently to "Unknown", and that switch is what failed the iOS
build-for-testing job. The Kotlin bridge derives its code reflectively
from the exception class, which is why Android compiled throughout.

The TypeScript boundary failed more quietly: FFI_ERROR_CODES is the
closed set of stable rejection codes, and anything outside it maps to
"Unknown", so a genuine mixnet refusal would have reached the app
unrecognized rather than as a typed failure.

Each surface gains the variant in the position the Rust enum declares
it, and the Swift contract-variant test gains its case, so all three
hand-written surfaces again enumerate exactly the twenty-one variants
the Rust enum defines. The Kotlin FfiOutcome test is keyed by FFI
method rather than by variant, so it is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The silent alpha now builds as a mainnet/testnet pair: alwaysontest
carries a distinct application id beside alwayson, prod, and beta, and
first-runs on the testnet default server. The chain choice rides a new
per-flavor default_chain_name res string, exported by RPCModule as the
defaultChainName constant and read by flavorDefaultChainName(), which
steers only the no-persisted-settings path; a stored server setting
always wins, and any absent or unexpected constant falls back to
mainnet. A unit test pins that fallback over every module shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On-device verification of the alpha caught a policy hole: a failed
mixnet enable leaves the wallet's mode at off, and with the mixnet UI
withheld, a send or a CEX price fetch proceeded over clearnet
silently. Fail-closed lived partly in the view layer, which is exactly
what the silent flavors remove.

The gate now lives in the backend. WalletBackend.sendTransaction
refuses unless the coordinator's last report is ready, and getZecPrice
refuses through the shared mixnetGate module the coordinator mirrors
its readiness into; both refusals carry the Nym mixnet marker so
classifySendFailure files them as mixnetRefusal, never a server
problem. Because the silent flavors have no human re-enable path, the
coordinator gains an auto-recovery loop that re-runs the enable a
minute after a failure, a died transport, or an unconsented off.

Fixing the suite that pins this exposed a real defect: schedulePolling
tore down through stop(), so every reschedule closed the gate and
cancelled pending recovery. Rescheduling now clears only the poll
interval. nymTransport also resolves its native module lazily — eager
capture at import coupled every walletBackend importer to the host's
NativeModules shape and broke the unit environment — and the coordinator
logs enable failures and the silent flavors log their withheld views,
so the alpha stays diagnosable with a stock UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The price surface flattened four genuinely different failures — the
fail-closed gate refusal, a typed FFI rejection, the oracle reporting
failure in its payload, and a malformed payload — into one sentinel
number and one string, which cost exactly the diagnostic information
the silent alpha needed when Gemini-over-mixnet failed on device.

getZecPrice now returns ZecPriceOutcome, a six-arm discriminated union
grounded in the real producers, with the ZingolibError variant code
preserved on the rejection arm and offending payloads carried verbatim.
The store consumes it through an exhaustive handler record dispatched
by matchZecPriceOutcome: one handler per arm, each narrowed, so a new
arm fails compilation at the record, by name, with no default to
forget (ADR 0004). Every failure arm also lands in the dev log — the
snackbar renders only a headline, and the silent flavors have no other
price diagnostics. Visible behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Typed outcome unions that cross a module boundary are consumed through
an exhaustive handler record dispatched by a single generic match
function, so a new arm fails compilation at every consumer, by name,
with no default to forget. Pure local folds may keep a switch with a
never default. First implemented for the price-fetch surface.
…e TLS fix

The pin advances to zingolib b03b9763, whose zingo-price verifies the
oracle fetch against the compiled-in webpki bundle instead of an empty
manual root store — the UnknownIssuer failure that made every price
fetch die at the first handshake, proven and fixed by probing through a
live emulator nym tunnel.

The FFI's error text now renders the full source chain, deepest cause
last. Display alone truncates: that UnknownIssuer hid for a whole
debugging session under three layers of 'request failed', and the chain
is the diagnostic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four nulls in the audit carried a concrete misinterpretation-failure path.
Each collapsed several distinct states into a single `null` that the reading
code then had to guess at, and in each case the guess was wrong for at least
one reachable input. This commit replaces each of them with a pure,
side-effect-free interpretation function returning a discriminated union
whose members name the states the null used to conflate, following the
precedent this branch already set for price fetching and send failures
(ADR 0002: errors are types).

`fetchWallet` answered a bare null for an FFI rejection, an empty payload,
and an unparseable payload alike, and answered a truthy empty object when the
payload parsed but carried no key material. The recovery-info caller in
LoadedApp tested `if (wallet)` and so treated every one of those as nothing
to do, skipping the store step in silence and leaving the user with an
enabled setting, no backup, and no error. `interpretWalletFetchResult` names
all five outcomes, `fetchWalletOutcome` exposes them, and the caller now
reverts the toggle and tells the user when nothing was stored. A birthday of
zero, which regtest wallets really have, survives interpretation instead of
being dropped by a truthiness check.

`selectingServer` resolves a server only when its probe answered, so every
resolved probe describes a reachable server. Three callers in LoadingApp
nevertheless read the resolved latency with truthiness, which reads a zero
millisecond measurement as the null "probe failed" state. A localhost or LAN
regtest server can answer within the same millisecond, so the custom-server
modal rejected healthy servers and `checkServer` reported them dead.
`serverProbeVerdict` decides reachability once, strictly.

The Receive screen stores an address index whose null means the addresses
effect has not run, but that effect encodes an empty filtered list as index
zero. Six read sites treated a non-null index as proof that an address
exists, and indexing an empty list there throws. `deriveAddressSelection` is
total over every list-and-index pair, separates not-loaded from empty, and
clamps a stale index left over from a previous list.

VerifyAddress stored `is_wallet_address` directly off `JSON.parse` behind a
strict null gate, so a payload missing the field stored undefined, passed the
gate, and rendered the definitive "this address does not belong to you". A
verification screen reporting a confident false negative is the worst form
this class of bug can take. `interpretCheckAddressResult` requires an actual
boolean and reports anything else as malformed, which the screen surfaces
instead of rendering as a verdict.

Every function is covered by unit tests exercising the inputs that triggered
the misinterpretations: a payload with no key material, a zero birthday, a
zero millisecond probe, an empty list stored as index zero, a stale index,
and a payload lacking `is_wallet_address`. Each assertion was first run
against a verbatim transcription of the current production logic, where all
five fail, the empty-list case with the TypeError it throws in the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…erywhere

The previous commit introduced two patterns in duplicate, and this branch
already carried two more copies of one of them. This commit defines each
pattern once and applies it at every site.

`deriveListSelection` (app/utils/listSelection.ts) generalizes the Receive
screen's address-selection derivation and replaces the two-sentinel guard in
the address book's detail sheet, which stores null for a closed sheet and -1
for Add-new mode. The shared form drops the previous clamping of a stale
index: the address book showed why that policy was wrong, since clamping
would open an edit sheet on a different contact than the user chose, so a
stale index now designates nothing anywhere.

`decodeFfiJson` (app/walletBackend/ffi.ts) is the transport-level triage —
rejection, empty payload, unparseable payload, or JSON — that
interpretWalletFetchResult, interpretCheckAddressResult, getZecPrice, and
isWalletAddress each performed themselves. All four now start from the
decoded arm and add only their own domain validation. As a consequence
getZecPrice classifies a non-object JSON payload as malformed rather than
whatever member access on it happened to produce.

matchWalletFetchOutcome and its handlers type had no consumer and are gone.
Module comments now describe only the current contracts; the historical
misreadings live in the audit document and the regression tests, which cite
them because that is what the tests exist to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
updateToField took five positional slots — address, amount, amountCurrency,
memo, includeUAMemo — where null meant "leave this field alone", the empty
string meant "clear it", and four of the five slots shared the type
`string | null`. A caller stating which field it writes had only slot
position to say so, and the audit (docs/null-audit.md, items 5-7) ranked
that protocol the codebase's largest remaining null hazard: transposing two
arguments compiles cleanly and silently writes the wrong field.

A form write is now a value of `SendFieldUpdate`, a discriminated union
whose discriminant names the field, and updateToField takes a batch of
them. The transposition class of mistake is thereby inexpressible: the
regression test's scenario — "the user typed 2 ZEC" expressed one slot to
the right — ran the amount coupling backwards and left the form holding
2 / 35 ≈ 0.057 ZEC, a wrong-amount transaction waiting for a signature,
with the type system silent. That test failed against a verbatim
transcription of the positional protocol (expected '2', received
'0.05714286') and passes by construction against the union.

The field arithmetic lives in `applySendFieldUpdates`, a pure fold over the
current field values: it owns the one coupling in the form (the two amount
fields are a single value in two units, so writing either recomputes or
clears its counterpart) and is exhaustively switched over the union, so a
new field arm fails compilation until every consumer handles it.
updateToField keeps only the effects: the async URI-address path and the
state writes, which now touch exactly the fields a batch changed.

Writing the pure core surfaced one dependency wart: the locale number
parsers lived on the Utils class, whose module imports the native bridge,
so nothing pure could import them. They now live in
app/utils/localeNumber.ts as a leaf module and Utils delegates to it.

The audit's tally attributed 73 of Send.tsx's 90 nulls to this protocol;
they are gone, and every remaining null in the file is one the audit rated
idiomatic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An audit with knip, ts-prune, and ts-unused-exports over all 383
TypeScript sources found no unused files, one dead implementation, nine
dead barrel re-exports, and twenty-six exports with no importer. Only
findings all three tools agreed on were acted on.

The dead implementation was openOptionsPanel, removed together with its
listener wiring (_openListener and registerOpen). Its siblings toggle
and close remain in use from LoadedApp.

The dead re-exports came out of two barrels. app/walletBackend/index.ts
loses FfiError, FfiErrorCode, PriceRouteAttestation,
ZecPriceOutcomeHandlers, StartMigrationRoute, doSaveBackup, and
cancelIronwoodMigration, all of which every consumer imports from the
defining module instead. components/OptionsPanel/index.ts loses its
default export and OptionsPanelProps for the same reason.

Twenty-four symbols used only inside their own module keep their
declarations and lose the export keyword. Two flagged functions,
enableMixnet and stopMixnetTransport, stay exported: they are
documented, deliberately unwired mixnet API on this feature branch (the
exec fallback and the shutdown teardown), and un-exporting them fails
lint because nothing calls them yet. cancelIronwoodMigration likewise
has unit tests but no production caller. All three are flagged in the
pull request rather than removed.

Verified with tsc --noEmit and eslint over every touched file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase onto dev carried the coordinator forward from the feat/nym
line, where the poll constants were module-private. Dev's hardening-era
coordinator test imports STEADY_POLL_MILLIS to drive its fake timers,
so the constants return to the exported surface they had on dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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