Nym linear - #1240
Draft
zancas wants to merge 52 commits into
Draft
Conversation
The nym_mobile_adoption branch now carries the fix/ironwood-split-mobile merge (zingolib 365d8aba), so one pin serves both the mixnet seam and the mobile split APIs. The lock moves every zingolib-source package to that commit, and the pin comment records the standing conflict policy: on every merge from PR #1187, nym_mobile_adoption wins and the lock advances. The FFI layer adapts to what the ZIP 318 side of that merge retired. The cadence choice is gone from zingolib (the Poisson schedule draws every delay itself), so reschedule_parts now fails typed with MigrationCadenceFixed, start_ironwood_migration ignores its per_bucket argument, and migration_status reports per_bucket as null; the JSON and uniffi shapes are unchanged so the Kotlin, Swift, and TS layers keep compiling until the cadence surface is retired end to end. The error funnel maps the new mixnet-refusal variants (MixnetNotReady, NoEligibleBroadcastIndexer, MigrationBroadcastTargetIsSyncEndpoint), the nym build's price-fetch refusal replaces PriceFetchUnsupported, note splitting surfaces the queued preparation count and the awaiting_schedule step, and the retired CadenceFixed mapping is dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ZIP 318 Poisson schedule draws every broadcast delay itself, so the per-window cadence choice no longer exists in zingolib. This removes the surface that offered it, across every layer, instead of keeping a control that always refuses. In Rust, reschedule_parts and the MigrationCadenceFixed error variant are gone, start_ironwood_migration takes only the plan hash, and the status JSON drops per_bucket; the uniffi UDL and the checked-in Kotlin binding are regenerated to match. The Android and iOS bridge methods follow, as do their outcome-table tests. The FfiArgs u32 helpers stay: they are generic bridge plumbing with sibling callers and their tests use per_bucket only as an example name. In the app, the MigrationCadence chooser screen is deleted and splitting completion navigates straight to the schedule review, which never read the perBucket param it declared. Progress counts parts directly on the status screen and the history banner, since each part now has its own drawn window. The migrationcadence translation blocks are removed from all five languages, and the routing helper for the cadence screen goes with its tests. Verified locally: cargo check and clippy clean, tsc clean, all 428 jest tests and 95 snapshots pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CONTEXT.md gains the Mixnet Mode section (Mixnet Mode, Fail-closed, Silent alpha APK, and the Always On build flavor) resolved in the design session, and the CI section gains the mobileclient scenario contract.
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>
Absence of evidence is not evidence of absence: a silent success was indistinguishable from an unnoticed new failure mode, and the static no-clearnet-tier argument had no runtime companion. The pin advances to zingolib 9113eed5, whose update_current_price returns the price together with the tunnel endpoint it traveled through; zec_price carries both in the data channel. The TS surface types the attestation rather than nulling it: PriceRouteAttestation is 'attested' with the endpoint, or the named preAttestationNativeLayer case for payloads from native layers that predate it — never a bare null. Successful fetches now log price, route, and duration, so success and failure are both positively observable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Catalogue all 421 literal `null` occurrences across 352 lines and 80 files, assess whether each one's meaning is unambiguous, and rank the sites that can hide a misinterpretation bug. The nulls fall into nine categories, one of which is specific to this codebase: the arg-skip sentinel that `updateToField` uses in Send.tsx, where a positional null means "leave this field unchanged" while an empty string means "clear it". Four sites carry a concrete misinterpretation-failure path and are fixed in the commit that follows. Seventeen more are ambiguous without a demonstrated failure, and the remaining majority are idiomatic. The audit records the review heuristic it produced: the defect is never the null declaration, it is the loose read that follows. Co-Authored-By: Claude Opus 5 (1M context) <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>
An on-device hang ran about five minutes to tls handshake eof through a half-dead tunnel, with the UI spinner circling the whole time. The fetch is now raced against a 25-second watchdog whose loss is a typed timedOut arm of ZecPriceOutcome, so the spinner is provably released whatever the native call does underneath. Classification moves into classifyPriceFetch, a pure and total function over RawPriceFetch (the settled decode with its elapsed time, or the watchdog firing — nothing else is constructible). Every arm carries its timing, and describePriceOutcome renders the one log line per attempt through the ADR 0004 handler record, success and gate refusal included, so a missing line always means no attempt finished. The store renders the new arm as a timeout snackbar, forced by the exhaustive record. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pin advances to zingolib aa4c80af, the dev merge that renamed the drain family to immediate migration and unified the value-left-behind vocabulary. The FFI function names are unchanged; internally the mobile crate now calls plan_immediate_migration, quick_immediate_migration, and immediate_migration_progress_handle, matches ImmediateMigrationPhase, drops the removed ActivationBoundaryPending error arm, and maps the new IronwoodEraTooYoung variant. Payloads follow upstream end to end: stranded becomes residual in the plan, execution, and split-plan surfaces, next_wakes becomes upcoming_windows with window_opens_unix_time and latest_target_unix_time, and the not_due batch report carries window_opens_unix_time. The TS payload types and the four migration components rename in lockstep; rendered copy and i18n keys are untouched. CONTEXT.md retires Stranded into a unified Residual entry, dated to the upstream unification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dev merge's resolution of walletBackend/index.ts kept a stale export block naming routeCadencePlan and routeRescheduleParts, which left with the cadence surface in the ZIP 318 no-cap work, and duplicated routeStartMigration. The block goes; typecheck is clean again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester who hit the always-on gate saw only "Error fetching the price from the Internet." and could report nothing else. zecPriceFailureReport renders every failed ZecPriceOutcome arm as one string under a stable headline, every field verbatim: the gate refusal text, the watchdog bound, the ffi code and message, the full zingolib oracle error chain, the malformed payload with its parse detail, and the elapsed time where the arm carries one. It returns null on success and is exhaustive through matchZecPriceOutcome, a new arm fails compilation until it declares its report. The report is built to feed createAlert with the sendEmail support button, which pre-fills an email carrying the text with device and version info. The unit tests pin each arm's report, the gate refusal verbatim among them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Issue #1221 reports a stock Zingo user under NymVPN who cannot sync and can offer only a generic error. Two recorded facts make the report legible: Nym exits carry 443 cleanly and mishandle lightwalletd's 9067, and the stock server list mixes both ports. The plan closes the diagnosis gap with a Connection Doctor: a staged per-server probe (connect, TLS, GetLightdInfo) built on zingolib's net-diag taxonomy extended to the sync path, surfaced in a debug-build Settings screen with a copy-out report, delivered to the reporter as a debug APK with a two-run ask (VPN off, VPN on). The plan also defines the pin collaboration protocol for the several agents updating the zingolib pin: the manifest comment is law, one bump one commit, a verification floor per bump, upstream milestones announced in the design doc rather than commit archaeology, and a mobile-adaptation-required marker for breaking surfaces. AGENTS.md gains a pending-plans pointer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Advances the pin to the current head of zingolib#2527, eleven days of upstream work past the previously locked 9113eed5. The lock also picks up the git-pinned librustzcash patch (e0b48bb5) that rev carries. Four upstream API changes reach the FFI, adapted here. The price fetch split into a clearnet method returning a bare f32 and a nym-feature update_current_price_over_mixnet carrying the via_socks5 attestation; zec_price now dispatches through mixnet_route, the shared fail-closed resolver, so the refusal semantics are unchanged and the clearnet leg omits the attestation field. start_ironwood_migration grew a per_bucket override, passed as None because the Poisson schedule leaves no cadence to choose. MigrationPhase::NoteSplitting lost its queued field and SplitStep lost AwaitingSchedule, so their JSON renderings shrink to match; nothing app-side read either shape. MigrationError::CadenceFixed is mapped for exhaustiveness only, since the retired cadence surface never calls reschedule_parts. Verified with cargo check, clippy, and fmt on the FFI crate and the full jest suite (487 tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester who hit a price failure saw a snackbar compose the headline with one detail and vanish, nothing to copy and nothing to send. The store now routes every failed fetch through zecPriceFailureReport and splits the surfacing by initiator. A user-initiated fetch raises createAlert with the complete report and the Support button, which pre-fills an email carrying the text plus device and version info. The 60 s auto-refresh keeps to a snackbar built from the report's first detail line, since a modal firing on a timer would interrupt whatever the user is doing. The per-arm snackbar match in the store retires, its exhaustiveness obligation already living in the transform. PriceFetcher threads setBackgroundError and the zingolib version from context into the store's deps. The surfacing helper is exported for its unit test, which pins the alert path (report and version threaded through), the snackbar path (refusal text intact), the malformed-payload title, and silence on success. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zingolib#2527 (nym_mobile_adoption) merged into zingolib dev at 3d2f2aa0 on 2026-07-27 and the branch was deleted. The branch-based pin was dangling, the exact failure mode that broke every dev-based CI resolve when feat/ironwood was deleted last week. All three zingolib-repo dependencies now pin zingolib dev, whose head is the merge commit, one commit past the previously locked b5de9bca with identical content. The manifest comment changes in the same commit, per the pin collaboration protocol, and now names dev as the branch that wins merge conflicts. Verified per the protocol floor: cargo check --workspace, cargo fmt --check (one pre-existing diff in android/tests/e2e_tests.rs predates this commit and is untouched), tsc, and the full jest suite. No rust/lib/src change, so no release-Kotlin compile was required. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Connection Doctor's native leg (the nym-diagnostics plan, Workstream A). probe_server parses the target uri at the boundary (unparseable input crosses as the typed InvalidInput error) and runs zingolib's paired clearnet/mixnet GetLightdInfo probe with a fifteen-second bound per leg. The result crosses UniFFI as structured dictionaries — ProbeReport with two ProbeLeg records — rather than JSON prose; the legs' detail strings stay opaque until the net-diag taxonomy lands fielded outcomes (zingolib#2560 records that direction). The Android and iOS native modules serialize the reports to JSON only at the React Native bridge, the bridge's own boundary format. A user-invoked diagnostic: the clearnet leg contacts the target from the real IP, and nothing calls this on an automatic path. No wallet lock is held across the probe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The app leg of the nym-diagnostics plan, Workstream A: the diagnosis issue #1221's reporter could not produce, in the user's own hands. The screen probes the current server first and then every non-obsolete stock server for its chain, sequentially so results render as they land and one dead target never multiplies load. Each row shows the paired legs with verdicts, timings, and the leg detail; a finished run copies to the clipboard as one markdown document under a stable headline, ready to paste into a GitHub issue. Interpretation is a pure, total function from the decoded FFI payload to a typed outcome union, dispatched through an exhaustive handler record (ADRs 0002 and 0004), so a new outcome arm fails compilation at the screen and at the report until each decides what it renders. The menu entry is gated to debug builds per the plan: the debug-signed APK handed to a reporter has it, stock release flavors do not. The unit tests cover every interpreter arm and every report section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI's jest coverage run failed both new Doctor suites at import time: the tests reach interpretServerProbe through connectionProbe.ts, which also imports RPCModule, and NativeModules.RPCModule is undefined in that environment. The plain local run masked it. The interpreter, the outcome union, and the handler record move to serverProbeOutcome.ts, which imports the FFI module by type only and loads without the React Native runtime; connectionProbe.ts keeps just the effectful probeServer wrapper. The report transform and the unit tests now import only the pure module, which is the house shape (ADR 0004 consumers dispatch over data, transforms stay pure) this code should have had from the start. Verified with tsc, eslint, and jest --coverage (76 suites, 501 tests), the invocation CI runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Temporary pin to zingolib#2561 (net_diag_taxonomy, head 950892e3), Workstream B's implementation: probe legs carry ProbeSuccess or NetOpFailure as data, the staged sync-path probe probe_sync_server lands, and the price path gains typed request failures with client bounds. Four commits past the previously locked dev head 3d2f2aa0; two carry the mobile-adaptation-required marker (4dd29ef6, 96c4e8d4), and the FFI adaptation is the separate commit landing with this one. The manifest comment changes here, per the pin collaboration protocol, and the pin reverts to dev when #2561 merges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c probe The adaptation landing with the net_diag_taxonomy pin bump, covering both of its mobile-adaptation-required commits (96c4e8d4, 4dd29ef6), and a breaking reshape of the probe surface this branch introduced two days ago: every closed possibility space now crosses the FFI as an exhaustive enum, not a boolean beside an optional pair, and no absence is a bare null. ProbeLegOutcome is Answered(info) or Failed(failure); ok and detail are gone, both derivable. MixnetLeg is Probed(leg) or NotCarried — the proxy-not-ready case has exactly one producer and now has its name. SyncStageOutcome is Passed or Failed(failure), and SyncServerVerdict is Reachable(info) or Stopped. The failure record everywhere is the net-diag taxonomy's: kebab-case stage, target, and the cause chain as a vector, never a concatenated string. Step and stage names stay open strings on the FfiErrorCode precedent: upstream may add stages, and a closed union would break forward compatibility on an open space. probe_sync_server joins the FFI: the staged sync-path probe (tcp-connect, tls-channel, grpc-info, each bounded and timed, stopping at the first typed failure), lock-free and client-free, so the Doctor runs it per configured server. The screen probes every server through it and adds the paired covered-surface probe for the current server, whose DoctorRun arm carries the pairing structurally. The native modules serialize each enum as a kind-discriminated JSON object at the React Native bridge, and the pure interpreters validate exactly those shapes into the same unions. Verified per the protocol floor: cargo check, clippy, and fmt; tsc; eslint; jest --coverage (76 suites, 504 tests); and the release-Kotlin compile over the regenerated sealed-class bindings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TaskDescription.Builder exists only from API 33, but the privacy-controls path guarded it with API 28. Every prod-flavor install on API 28 through 32 crashed at launch with NoClassDefFoundError before drawing a frame. The guard now requires TIRAMISU, and older devices keep the default blank recents card that FLAG_SECURE already produces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every variant of this app embeds its JS with --dev false, so __DEV__ is false in any APK that runs without Metro. The Doctor's __DEV__ gate therefore hid the screen in exactly the debug-signed handoff APK the nym-diagnostics plan builds for reporters. RPCModule now exports debuggableBuild, read from the installed APK's FLAG_DEBUGGABLE bit rather than BuildConfig (the reproducibility rule), and the menu gates on __DEV__ || isDebuggableBuild(): Metro sessions and debug-signed APKs show the Doctor, stock releases hide it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staged proxy shim is a binary build product with nothing tying it to the manifest pin: UniFFI checksums guard only the interface and the golden contract test only the wire format. A .so built hours before ADR 0021's TLS fix rode three pin bumps undetected and fail-closed every mixnet enable on Android. Three pieces close that hole. consume-android-shim now stamps jniLibs/shim-provenance.txt with the bundle checkout's revision (dirty checkouts stamp +dirty and can never match a pin). The new attest-android-shim records that a staged shim covers a newer pin only after proving zingo-netutils unchanged between the revisions. The gradle task verifyShimProvenance runs before every mergeJniLibFolders and fails any assembly whose staged shim neither matches the locked revision nor carries an attestation; a build with no staged shim passes, since a shim-less APK degrades fail-closed by design. The pin collaboration protocol's verification floor names the new obligation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From net_diag_taxonomy at f701b105 to the zingolib#2568 stack at 220f682d, passing through the taxonomy head 756c7479 (the typed per-provider connect race, zingolib#2562). The new head carries the public mixnet_death_detail and mixnet_bootstrap_detail accessors and types the elapsed client bound on the via_socks5 operations (zingolib#2564), the surface the death-detail FFI wiring consumes next. No commit in the range carries mobile-adaptation-required, and the regenerated shim bindings are byte-identical to the committed copy. Verified with cargo check, tsc, the jest suite, and the release-Kotlin compile; the librustzcash re-lock follows the branch defaults as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A tester watching the price update could not tell which route carried it, and a tester hitting a failure could not copy the report out of the alert. Both surfaces now speak. The FFI's clearnet leg attests its route (via_clearnet in the payload), so the app distinguishes a deliberate clearnet fetch from a payload predating the attestation; the route type gains the clearnet arm and the dev log line names it. A user-initiated fetch now raises a snackbar naming the transport and the elapsed time (the auto-refresh stays silent), in all five locales. The failure alert gains a Copy button beside Support, since native alert text cannot be selected. Proven on the emulator: over the mixnet in 2.7 s through the SOCKS5 proxy, over clearnet in 0.7 s after the consent toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staged sync probe's stage lines named no transport, and a reader in the field parsed them as mixnet results. The sync path never rides the mixnet (ZIP-318: only broadcast and price-fetch are covered), so every run's section now opens with 'sync-path probe (clearnet):', in the screen rows and the copied markdown alike, and the unit test pins the heading. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A died transport reached the app as a bare mode, so "Mixnet connection lost" could not say what failed, and this week's false-death triage needed a debugger to read a cause the wallet had already recorded. The zingolib#2568 pin exposes mixnet_death_detail; this wires it through. The FFI returns the probes' ProbeFailure dictionary (one taxonomy shape wherever a failure crosses), the Kotlin bridge marshals absence named rather than null, and the coordinator fetches the record exactly when the polled mode is died. The view carries it only in that state — a failed or absent detail degrades to the bare verdict, since the detail is evidence, not policy — and Settings renders the stage, target, and cause chain under the died status through the Doctor's shared renderer. The presenter test pins all four arms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Testers running the 2026-07-27 handoff APK reported the whole app turning sluggish with "Check Balance in Dollars" enabled, freezing in roughly 20-second spells. The mechanism: zec_price() ran under the global LIGHTCLIENT write lock and held it across the network fetch, which zingo-price bounds at 20 seconds per attempt. Every other FFI call queued behind it, and the app's immediate retry doubled the hold to up to 40 seconds of every 60-second refresh cycle. The price-fetch flow now never obtains the wallet lock (ADR 0005). The route is planned purely from a mixnet-state snapshot recorded by calls that already hold the lightclient for their own reasons (store_client, enable/attach/disable, and the app's mode poll), resolved through zingolib's fail-closed resolve_route. The fetch itself calls zingo-price directly with no lock held, and a successful observation lands in a bounded in-memory window of at most 1000 points, oldest dropped first. Persistence becomes best-effort by contract: an observation reaches the wallet only when some other operation takes the write lock while it is buffered, and it may be lost. The piggyback write is not yet wired; it needs a public price-recording API on the pinned zingolib, and until that lands the window buffers without draining. The fetch payload the app displays is unaffected, and its route attestation (via_socks5 / via_clearnet) is unchanged. The change was test-driven. A new unit test holds the write lock for its whole duration and requires zec_price() to settle anyway, failing precisely when the flow touches the lock: red at a 3-second timeout before the rewrite, green in milliseconds after it. Pure-core tests pin the observation window's rotation at the cap and every mixnet state's fail-closed plan arm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Price surface section names the vocabulary ADR 0005 decides: a price observation, the bounded observation window, the piggyback write that is the surface's only persistence path, and the route snapshot the fetch plans from. The Indexers section, drafted in a parallel design session, fixes the operator-centric vocabulary for indexer endpoints: trust, diversity, and exclusion are reckoned per operator, never per DNS name, and a Broadcast Indexer is never the Sync Indexer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From socks5_typed_timeout at 220f682d to the top of the zingolib#2569 stack at 2d78c1be3, which carries the attach retune (30 s round-trip bound, the named 61 s readiness budget), the typed MixnetTiming record, the death report with its latch moment and clamped age, and the LightClient wrapper flagged mobile-adaptation-required — the adaptation is the wiring commit landing with this one. The bump also consolidates zingo-price into the workspace pin block: the lock-free price commit had pinned it directly on the old branch, which resolved two copies of the crate the moment the pin moved. All five zingolib-repo pins now live in one place under the protocol comment. Verified with cargo check, clippy, tsc, the jest suite, and the release-Kotlin compile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…idge The mobile adaptation for the attach_retune pin. mixnet_death_report supersedes mixnet_death_detail as the one death surface: the wallet computes the clamped age at read time (a stepping clock reads as zero), so each poll carries a fresh staleness and no consumer subtracts timestamps; the typed cause rides beside it, absent only for a causeless death. mixnet_timing crosses the attach readiness budget and the round-trip bound, so the app paces its connect patience from the constants the wallet's gates actually run. On the app side the view's death evidence becomes a discriminated union — notDied, unreported (a died verdict whose report call failed, previously an anonymous pair of nulls), and reported with the age and optional cause — mirroring the latch fold the wallet itself received in zingolib#2569. Settings renders the cause lines with a latched-age line beneath the died status, and while bootstrapping shows the wallet's own budget as the patience hint, in all five locales. The presenter test pins every evidence state, including the causeless death that still reports its age. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The second half of ADR 0005. A lock-free price fetch buffers its observation in the bounded in-memory window; this commit wires the window's only exit. Acquiring the write lock in with_initialized_lightclient now drains pending observations into the wallet's price list before the operation's closure runs, so persistence rides whatever operation next takes the lock for its own reasons. No zingolib change was needed: the recording path is public on the current pin (LightClient::wallet, the price_list field, and PriceList::record_current_price). The one door the public path lacks is save_required, which stays pub(crate) upstream, so a drained price reaches disk with the next operation that dirties the wallet. That sits inside the surface's best-effort contract. The change was test-driven. The red test buffered an observation, invoked a write-lock operation, and found the wallet's current price empty; wiring the drain turned it green. The price tests share a small support module (the serialization guard and the offline fixture wallet), a sibling of the private helpers in lock_discipline_tests.rs, to be unified when that suite settles. The lock-freedom test now also resets client state first, so it holds under plain cargo test threading as well as nextest's process-per-test isolation. Verified against the attach_retune pin at 2d78c1be: 43 of 43 unit tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From attach_retune at 2d78c1be to price_sources at 47e44b79e, which is attach_retune's exact pinned head plus one commit: the three-source price race in zingo-price (Gemini, Kraken, CoinGecko; first success wins, an all-fail report names every operator's typed failure). Nothing else moves, and zingo-netutils is untouched between the two revisions, so no shim restage is due under the staged-shim provenance rule. The manifest comment moves with the pin, per the pin collaboration protocol in docs/agents/nym-diagnostics-plan.md. The full mobile unit suite passes on the new head (54 of 54). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The lock-free price fetch now runs zingo-price's race_current_price over the fail-closed route: Gemini, Kraken, and CoinGecko fetched concurrently, first success reported, losing legs cancelled (ADR 0006). The payload names the winning source beside the route attestation, and the JS layer passes the additive field through untouched. An all-fail settles as one typed failure whose text carries every source's cause chain, so the failure report stays diagnosable per operator. The winning observation still lands in the bounded window for the piggyback write, and the lock-freedom contract is unchanged: the race runs entirely outside the LIGHTCLIENT lock, within zingo-price's 20-second per-leg bound. A payload test pins the winner's name and both route attestations; the full suite passes (56 of 56). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The store retried any non-price outcome immediately. After a watchdog timeout that stacked a second native call behind one possibly still in flight, and it retried fail-closed gate refusals the glossary says are never retried. The retry decision is now an exhaustive record over the outcome union (ADR 0004): settled failures (noData, oracleError, malformedPayload, ffiRejection) keep their one immediate retry, while timedOut and gateRefusal never retry. With the native race in place, a settled failure's retry also naturally reaches the re-raced sources. Three tests drive a fresh store against a mocked backend: the timeout and the refusal each launch exactly one native call, and a settled failure still retries once. The suite runs on fake timers with explicit teardown so the store's cooldown and visible-floor timers cannot leak a force-exited jest worker. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The price tests carried their own copy of the serialization guard and the offline fixture wallet, left with a note to unify once the lock-discipline suite landed. Beyond the duplication, the copy held a separate mutex, so under plain cargo test's in-process threads the price tests and the lock-discipline tests never serialized against each other while sharing the one global LIGHTCLIENT. Both families now take the same guard and fixture from lock_discipline_tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The linearization replays feat/nym's 83 non-merge commits onto dev and drops its nine merge commits. Whatever content those merges' conflict resolutions carried (dev-side surfaces the branch reshaped or retired, such as the quick-split machinery) has no replayed commit to land in, so this commit sets the tree to feat/nym's exactly. After it, the linear branch and feat/nym agree byte for byte. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
From price_sources at 47e44b79 to the census (zingolib#2571) at ba03ec07, one commit atop the price race: the indexer census, the sole source of truth for endpoints, flagged mobile-adaptation-required. The adaptation is the consumption commit landing with this one. Verified with cargo check, tsc, the jest suite, and the release-Kotlin compile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mobile adaptation for the indexer_census pin (zingolib#2571). The FFI exposes indexer_census() as fielded entries, and the census crosses the bridge in RPCModule's constants rather than a method, because the server list is consulted at JS module load — before any async bridge call could resolve — for the flavor's default server. serverUris becomes a pure projection of that constant: URIs, chains, defaults, and obsolete flags come from the census, and the app renders regions by translating the census's region keys. The old static list survives only as staticServerFallback, serving a native layer that predates the census constant (iOS until its export lands, and tests without a mock); a malformed census degrades to the fallback whole, never partially. The projection test pins the mapping, the fallback gate, and the all-or-nothing rule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e census The live registry fetch ran at boot (auto mode), at Settings open, at list-mode validation, and in every failure recovery. Each call beaconed the wallet's presence to one operator from the real IP, and the response steered which indexer the wallet trusted next — a funneling surface aimed at witness rotation and operator diversity. The census (ADR 0007) already carries the registry's static value on release cadence. Auto mode now probes the census's active entries by latency; list mode validates the stored server against the census and promotes to auto only when the census retired it; Send-failure and RPC-failure recovery select from the census, excluding the failed server. fetchServerList is deleted. The Settings picker populates synchronously from the census, which is the one snapshot change. 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>
…base The rebase resolved every Cargo.lock and zingo.kt conflict by taking dev's side, because both are generated artifacts whose only truth is their source of record. This commit restores that truth for the rebased tree: the lockfile is regenerated against the branch's indexer_census pin, and the checked-in Kotlin bindings are regenerated from the branch's final zingo.udl with the workbench's uniffi-bindgen invocation, restoring the probe, death-report, and timing surfaces the rebased arc declares. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rebase met two independent regenerations of the same snapshot (dev's banner restyle and the nym arc's refusal reclassification) and resolved them by taking dev's side, leaving the snapshots answering to neither. Retaken from the merged code; the full jest suite runs green (78 suites, 516 tests, 95 snapshots). 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.
No description provided.