feat: the typed-surfaces arc, flavors, fail-close, and the convergence-proof upgrades - #1243
Open
zancas wants to merge 12 commits into
Open
feat: the typed-surfaces arc, flavors, fail-close, and the convergence-proof upgrades#1243zancas wants to merge 12 commits into
zancas wants to merge 12 commits into
Conversation
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>
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.
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:
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.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-fetchgating 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