diff --git a/App.tsx b/App.tsx index 5db5b0a82..af35e3cae 100644 --- a/App.tsx +++ b/App.tsx @@ -130,7 +130,7 @@ export const basicTheme: ThemeType = { const Stack = createStackNavigator(); -export const navigationRef = createNavigationContainerRef(); +const navigationRef = createNavigationContainerRef(); const App: React.FunctionComponent = () => { const [theme, setTheme] = useState(advancedTheme); diff --git a/CONTEXT.md b/CONTEXT.md index 84bd3a41c..56e3b216d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -100,6 +100,31 @@ and completion-time vocabulary on "residual") The single user approval of an exact plan hash before anything is signed or sent. Covers the whole migration, both phases. +## Mixnet Mode + +**Mixnet Mode**: +Routing the send (transaction broadcast) and price-fetch surfaces over the +Nym mixnet. Synchronization is never covered; the IP-correlation disclaimer +(ZIP-0318) states that boundary. Modes: `off`, `bootstrapping`, `ready`, +`died`. + +**Fail-closed**: +The policy that when Mixnet Mode is anything but `off`, a covered surface +that cannot reach the mixnet refuses rather than falling back to clearnet. +A refusal is not a server error and is never retried. + +**Silent alpha APK**: +An alpha build of the app that routes the covered surfaces over Nym with +the stock (pre-Mixnet-Mode) UX/UI — no toggle, no banners, no disclaimer +screen. Its purpose is isolating transport behavior from UI work. +_Avoid_: silent mode (it is a build, not a runtime mode) + +**Always On** (build flavor): +The build flavors that produce the silent alpha APKs: Mixnet Mode is enabled +unconditionally at wallet initialization and cannot be disabled at runtime. +Two network variants exist — `alwayson` first-runs on mainnet, and +`alwaysontest` first-runs on testnet — installable side by side. + ## CI **Blocking check** — a PR CI job whose failure fails the pull request. diff --git a/__tests__/CheckAddressVerdict.unit.test.ts b/__tests__/CheckAddressVerdict.unit.test.ts new file mode 100644 index 000000000..18fa1b307 --- /dev/null +++ b/__tests__/CheckAddressVerdict.unit.test.ts @@ -0,0 +1,56 @@ +/** + * @format + */ + +import { interpretCheckAddressResult } from '../components/Receive/components/checkAddressVerdict'; +import { FfiResult } from '../app/walletBackend/ffi'; + +const ok = (value: string): FfiResult => ({ ok: true, value }); +const rejected = (): FfiResult => ({ + ok: false, + error: { code: 'InvalidInput', message: 'bad address' }, +}); + +describe('interpretCheckAddressResult', () => { + test('is_wallet_address true is a positive verdict', () => { + const raw = JSON.stringify({ is_wallet_address: true, account_id: 0 }); + expect(interpretCheckAddressResult(ok(raw))).toEqual({ kind: 'mine' }); + }); + + test('is_wallet_address false is a negative verdict', () => { + const raw = JSON.stringify({ is_wallet_address: false, account_id: 0 }); + expect(interpretCheckAddressResult(ok(raw))).toEqual({ kind: 'notMine' }); + }); + + test('a typed FFI rejection is named, and carries its code', () => { + expect(interpretCheckAddressResult(rejected())).toEqual({ + kind: 'ffiRejection', + code: 'InvalidInput', + message: 'bad address', + }); + }); + + // EVIDENCE of the misinterpretation this replaces: the screen stored + // `is_wallet_address` straight off JSON.parse behind a `verifyOK !== null` + // render gate, so a well-formed payload lacking the field stored + // `undefined`, passed the gate, and rendered the definitive "this address + // does not belong to you" — a confident false negative produced by a + // check that never returned a verdict. + test('a payload without is_wallet_address is malformed, not "not your address"', () => { + const raw = JSON.stringify({ encoded_address: 'u1aaa' }); + expect(interpretCheckAddressResult(ok(raw)).kind).toBe('malformed'); + }); + + // EVIDENCE, same gate: a truthy non-boolean must not read as "yours". + test('a non-boolean is_wallet_address is malformed, not a verdict', () => { + const raw = JSON.stringify({ is_wallet_address: 'yes' }); + expect(interpretCheckAddressResult(ok(raw)).kind).toBe('malformed'); + }); + + // EVIDENCE: a parse failure used to be swallowed by a bare catch, so the + // user tapped Verify and nothing happened at all. + test('an unparseable or empty payload is malformed, never silent', () => { + expect(interpretCheckAddressResult(ok('not json')).kind).toBe('malformed'); + expect(interpretCheckAddressResult(ok('')).kind).toBe('malformed'); + }); +}); diff --git a/__tests__/ListSelection.unit.test.ts b/__tests__/ListSelection.unit.test.ts new file mode 100644 index 000000000..73b6c7ca6 --- /dev/null +++ b/__tests__/ListSelection.unit.test.ts @@ -0,0 +1,56 @@ +/** + * @format + */ + +import { deriveListSelection } from '../app/utils/listSelection'; + +type Addr = { address: string }; +const addrs = (...names: string[]): Addr[] => + names.map(address => ({ address })); + +describe('deriveListSelection', () => { + test('a null index designates nothing', () => { + expect(deriveListSelection(addrs(), null)).toEqual({ + kind: 'noSelection', + }); + expect(deriveListSelection(addrs('u1aaa'), null)).toEqual({ + kind: 'noSelection', + }); + }); + + // EVIDENCE of misinterpretation at Receive.tsx (doCopy and the NAT/EA + // sheets): the populating effect encodes an *empty* filtered list as + // index 0, and the read sites treated `index !== null` as proof an + // address exists — so `tAddr[0].address` threw on an empty list. The + // correct pattern (null check plus length check) already existed in + // `currentAddress`; this function is its total, shared form. + test('an empty list stored as index 0 is empty, not a selected item', () => { + expect(deriveListSelection(addrs(), 0)).toEqual({ kind: 'empty' }); + }); + + test('a valid index selects that item', () => { + expect(deriveListSelection(addrs('u1aaa', 'u1bbb', 'u1ccc'), 2)).toEqual({ + kind: 'selected', + item: { address: 'u1ccc' }, + index: 2, + }); + }); + + // AddressBook stores -1 for "Add-new mode, no real item" alongside null + // for "sheet closed" — two sentinels for the same non-state. A negative + // index must never designate an item. + test('a negative sentinel designates nothing', () => { + expect(deriveListSelection(addrs('u1aaa', 'u1bbb'), -1)).toEqual({ + kind: 'noSelection', + }); + }); + + // A stale index can outlive a list refresh. Deliberately NOT clamped: + // presenting a different item than the user chose (an edit sheet opening + // on the wrong contact) is worse than designating none. + test('a stale index beyond the list designates nothing', () => { + expect(deriveListSelection(addrs('u1aaa'), 5)).toEqual({ + kind: 'noSelection', + }); + }); +}); diff --git a/__tests__/SendFieldUpdates.unit.test.ts b/__tests__/SendFieldUpdates.unit.test.ts new file mode 100644 index 000000000..d029d72b8 --- /dev/null +++ b/__tests__/SendFieldUpdates.unit.test.ts @@ -0,0 +1,140 @@ +/** + * @format + */ + +import { + applySendFieldUpdates, + SendFields, +} from '../components/Send/sendFieldUpdates'; + +const PRICE_USD = 35; + +const fields = (overrides: Partial = {}): SendFields => ({ + address: 'u1existingaddress', + amount: '', + amountCurrency: '', + memo: '', + includeUAMemo: false, + ...overrides, +}); + +describe('writing the ZEC amount', () => { + // REGRESSION EVIDENCE: updateToField used to take five positional slots, + // with the ZEC and fiat amount slots adjacent and identically typed + // `string | null`. Expressing "the user typed 2 ZEC" one slot to the + // right ran the coupling backwards — the form silently held + // 2 / 35 ≈ 0.057 ZEC (captured: expected '2', received '0.05714286'), + // and the type system could not object. Under SendFieldUpdate the write + // names its field, so that transposition is inexpressible: this same + // scenario now passes by construction. + test('sets amount to the typed text and computes the fiat counterpart', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'amount', value: '2' }], + PRICE_USD, + ); + expect(next.amount).toBe('2'); + expect(next.amountCurrency).toBe('70.00'); + }); + + test('writing the fiat amount computes the ZEC amount from the price', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'amountCurrency', value: '70' }], + PRICE_USD, + ); + expect(next.amountCurrency).toBe('70'); + expect(next.amount).toBe('2.00000000'); + }); + + test('an unknown price clears the counterpart instead of inventing one', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'amount', value: '2' }], + 0, + ); + expect(next.amount).toBe('2'); + expect(next.amountCurrency).toBe(''); + }); + + test('a non-numeric amount clears the counterpart', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'amount', value: 'not-a-number' }], + PRICE_USD, + ); + expect(next.amount).toBe('not-a-number'); + expect(next.amountCurrency).toBe(''); + }); + + test('the two amounts are one value: clearing either clears both', () => { + const cleared = applySendFieldUpdates( + fields({ amount: '1.5', amountCurrency: '52.50' }), + [{ field: 'amount', value: '' }], + PRICE_USD, + ); + expect(cleared.amount).toBe(''); + expect(cleared.amountCurrency).toBe(''); + + const clearedViaFiat = applySendFieldUpdates( + fields({ amount: '1.5', amountCurrency: '52.50' }), + [{ field: 'amountCurrency', value: '' }], + PRICE_USD, + ); + expect(clearedViaFiat.amount).toBe(''); + expect(clearedViaFiat.amountCurrency).toBe(''); + }); + + test('amounts truncate to their field widths', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'amount', value: '1'.repeat(30) }], + 0, + ); + expect(next.amount).toHaveLength(20); + }); +}); + +describe('independent fields', () => { + test('a plain address is stripped of whitespace', () => { + const next = applySendFieldUpdates( + fields(), + [{ field: 'address', value: ' u1a bc\n' }], + PRICE_USD, + ); + expect(next.address).toBe('u1abc'); + }); + + test('memo and includeUAMemo write without touching the amounts', () => { + const next = applySendFieldUpdates( + fields({ amount: '1.5', amountCurrency: '52.50' }), + [ + { field: 'memo', value: 'hola' }, + { field: 'includeUAMemo', value: true }, + ], + PRICE_USD, + ); + expect(next.memo).toBe('hola'); + expect(next.includeUAMemo).toBe(true); + expect(next.amount).toBe('1.5'); + expect(next.amountCurrency).toBe('52.50'); + }); + + test('a batch applies in order (the memo auto-seed pair)', () => { + const next = applySendFieldUpdates( + fields(), + [ + { field: 'amount', value: '0' }, + { field: 'memo', value: 'auto-seeded' }, + ], + PRICE_USD, + ); + expect(next.amount).toBe('0'); + expect(next.memo).toBe('auto-seeded'); + }); + + test('an empty batch changes nothing', () => { + const prev = fields({ amount: '1.5', amountCurrency: '52.50' }); + expect(applySendFieldUpdates(prev, [], PRICE_USD)).toEqual(prev); + }); +}); diff --git a/__tests__/ServerProbeVerdict.unit.test.ts b/__tests__/ServerProbeVerdict.unit.test.ts new file mode 100644 index 000000000..31b426e3c --- /dev/null +++ b/__tests__/ServerProbeVerdict.unit.test.ts @@ -0,0 +1,51 @@ +/** + * @format + */ + +import { serverProbeVerdict } from '../app/serverProbeVerdict'; +import { ServerUrisType } from '../app/AppState'; + +const probe = (latency: number | null): ServerUrisType => + ({ + uri: 'https://zec.rocks:443', + region: 'na', + chainName: 'main', + default: true, + latency, + obsolete: false, + }) as ServerUrisType; + +describe('serverProbeVerdict', () => { + test('a null probe result (all candidates failed or timed out) is unreachable', () => { + expect(serverProbeVerdict(null)).toEqual({ kind: 'unreachable' }); + }); + + test('an unmeasured probe (latency null) is unreachable', () => { + expect(serverProbeVerdict(probe(null))).toEqual({ kind: 'unreachable' }); + }); + + test('a measured probe is reachable and carries the measurement', () => { + const s = probe(57); + expect(serverProbeVerdict(s)).toEqual({ + kind: 'reachable', + server: s, + latencyMs: 57, + }); + }); + + // EVIDENCE of misinterpretation at LoadingApp.tsx:1017, 1103, and 1391: + // production reads the resolved latency with truthiness + // (`serverChecked && serverChecked.latency`), so a 0 ms measurement — + // two Date.now() calls landing in the same millisecond, e.g. against a + // localhost regtest server — is read as the null "probe failed" state. + // selectingServer.ts:33 only resolves a server that actually answered, + // so any resolved probe, 0 ms included, is a reachable server. + test('a 0 ms round trip is a reachable server, not a dead one', () => { + const s = probe(0); + expect(serverProbeVerdict(s)).toEqual({ + kind: 'reachable', + server: s, + latencyMs: 0, + }); + }); +}); diff --git a/__tests__/WalletFetchOutcome.unit.test.ts b/__tests__/WalletFetchOutcome.unit.test.ts new file mode 100644 index 000000000..0f8c98a8e --- /dev/null +++ b/__tests__/WalletFetchOutcome.unit.test.ts @@ -0,0 +1,97 @@ +/** + * @format + */ + +import { interpretWalletFetchResult } from '../app/walletBackend/utils/walletFetchOutcome'; +import { FfiResult } from '../app/walletBackend/ffi'; + +const ok = (value: string): FfiResult => ({ ok: true, value }); +const rejected = (): FfiResult => ({ + ok: false, + error: { code: 'Wallet', message: 'lightclient not initialized' }, +}); + +describe('interpretWalletFetchResult — seed mode (readOnly = false)', () => { + test('a complete payload yields the wallet', () => { + const raw = JSON.stringify({ + seed_phrase: 'abandon ability able about above absent', + birthday: 1_234_567, + }); + expect(interpretWalletFetchResult(ok(raw), false)).toEqual({ + kind: 'complete', + wallet: { + seed: 'abandon ability able about above absent', + birthday: 1_234_567, + }, + }); + }); + + test('a typed FFI rejection is named, and carries its code', () => { + expect(interpretWalletFetchResult(rejected(), false)).toEqual({ + kind: 'ffiRejection', + code: 'Wallet', + message: 'lightclient not initialized', + }); + }); + + test('an empty payload is emptyPayload', () => { + expect(interpretWalletFetchResult(ok(''), false)).toEqual({ + kind: 'emptyPayload', + }); + }); + + test('a non-JSON payload is malformedPayload', () => { + expect(interpretWalletFetchResult(ok('not json at all'), false).kind).toBe( + 'malformedPayload', + ); + }); + + // EVIDENCE of the misinterpretation this replaces: fetchWallet built + // `{} as WalletType` and copied fields only when truthy, so a payload with + // no seed still returned a *truthy* object. The recovery-info caller in + // LoadedApp tested `if (wallet)` and stored that empty object as the + // user's backup. + test('well-formed JSON without seed material is missingKeyMaterial, not a wallet', () => { + const raw = JSON.stringify({ no_of_accounts: 1 }); + expect(interpretWalletFetchResult(ok(raw), false).kind).toBe( + 'missingKeyMaterial', + ); + }); + + // EVIDENCE: the old `if (RPCseed.birthday)` guard dropped a genesis + // birthday of 0, which regtest wallets really have, so the stored + // recovery info silently lost its birthday. + test('a genesis (0) birthday survives interpretation', () => { + const raw = JSON.stringify({ seed_phrase: 'abandon ability', birthday: 0 }); + expect(interpretWalletFetchResult(ok(raw), false)).toEqual({ + kind: 'complete', + wallet: { seed: 'abandon ability', birthday: 0 }, + }); + }); +}); + +describe('interpretWalletFetchResult — viewing-key mode (readOnly = true)', () => { + test('a complete payload yields the wallet', () => { + const raw = JSON.stringify({ ufvk: 'uview1abcdef', birthday: 2_000_000 }); + expect(interpretWalletFetchResult(ok(raw), true)).toEqual({ + kind: 'complete', + wallet: { ufvk: 'uview1abcdef', birthday: 2_000_000 }, + }); + }); + + // EVIDENCE: same truthy field-copy pattern as seed mode — a payload + // without a ufvk still counted as a wallet. + test('well-formed JSON without a ufvk is missingKeyMaterial, not a wallet', () => { + const raw = JSON.stringify({ birthday: 2_000_000 }); + expect(interpretWalletFetchResult(ok(raw), true).kind).toBe( + 'missingKeyMaterial', + ); + }); + + test('a seed payload does not satisfy viewing-key mode', () => { + const raw = JSON.stringify({ seed_phrase: 'abandon ability', birthday: 1 }); + expect(interpretWalletFetchResult(ok(raw), true).kind).toBe( + 'missingKeyMaterial', + ); + }); +}); diff --git a/__tests__/flavor.unit.test.ts b/__tests__/flavor.unit.test.ts new file mode 100644 index 000000000..7fc89481b --- /dev/null +++ b/__tests__/flavor.unit.test.ts @@ -0,0 +1,54 @@ +/** + * Pins the flavor chain default (CONTEXT.md: the silent alpha APKs). + * + * flavorDefaultChainName steers only the first-run server default. It must + * be testnet exactly when the flavor exports "test", and mainnet on every + * other shape — an absent module, an absent constant, or any unexpected + * value — so the stock flavors and old native layers keep mainnet. + */ + +/** + * Loads a fresh flavor util against the given NativeModules shape. The + * util captures nothing at import time, but react-native itself must be + * mocked before the import chain pulls it in, so each case resets the + * registry and re-imports. + */ +function loadFlavorDefaultChainName( + nativeModules: Record, +): () => string { + jest.resetModules(); + jest.doMock('react-native', () => ({ NativeModules: nativeModules })); + const { flavorDefaultChainName } = require('../app/utils/flavor'); + jest.dontMock('react-native'); + return flavorDefaultChainName; +} + +describe('flavorDefaultChainName', () => { + it('is mainnet where the native module is absent', () => { + expect(loadFlavorDefaultChainName({})()).toBe('main'); + }); + + it('is mainnet when the module exports no constant', () => { + expect(loadFlavorDefaultChainName({ RPCModule: {} })()).toBe('main'); + }); + + it('is mainnet for the stock flavors (constant "main")', () => { + expect( + loadFlavorDefaultChainName({ RPCModule: { defaultChainName: 'main' } })(), + ).toBe('main'); + }); + + it('is mainnet for any unexpected constant value', () => { + expect( + loadFlavorDefaultChainName({ + RPCModule: { defaultChainName: 'regtest' }, + })(), + ).toBe('main'); + }); + + it('is testnet only for the testnet alpha flavor (constant "test")', () => { + expect( + loadFlavorDefaultChainName({ RPCModule: { defaultChainName: 'test' } })(), + ).toBe('test'); + }); +}); diff --git a/__tests__/walletBackend.alwaysOnGate.unit.test.ts b/__tests__/walletBackend.alwaysOnGate.unit.test.ts new file mode 100644 index 000000000..30db69e6d --- /dev/null +++ b/__tests__/walletBackend.alwaysOnGate.unit.test.ts @@ -0,0 +1,203 @@ +/** + * Pins the always-on flavors' fail-closed gate (CONTEXT.md: Fail-closed). + * + * Both covered surfaces must refuse rather than touch clearnet while the + * mixnet transport is not ready: the send path through WalletBackend's + * backend-layer gate, and the CEX price fetch through the module gate. The + * on-device alpha proved the hole this closes: a failed enable left the + * wallet at `off`, and with the mixnet UI withheld, a send went clearnet + * silently. The auto-recovery loop is also pinned here — the silent + * flavors have no human re-enable path. + */ +import { + MixnetCoordinator, + RECOVERY_RETRY_MILLIS, +} from '../app/walletBackend/modules/MixnetCoordinator'; +import { + COVERED_SURFACE_REFUSAL, + coveredSurfacePermitted, + recordMixnetTransportReady, +} from '../app/walletBackend/utils/mixnetGate'; +import { classifySendFailure } from '../app/walletBackend/transforms/sendFailureTransform'; + +jest.mock('../app/RPCModule', () => + require('../__mocks__/rpcModuleProxy').rpcModuleProxyMock(), +); +jest.mock('../app/walletBackend/utils/nymTransport', () => ({ + isMixnetAlwaysOn: jest.fn(() => false), + startMixnetTransport: jest.fn(), + stopMixnetTransport: jest.fn(), +})); + +import RPCModule from '../app/RPCModule'; +import { isMixnetAlwaysOn } from '../app/walletBackend/utils/nymTransport'; +import { getZecPrice } from '../app/walletBackend/utils/walletUtils'; + +const mockedBridge = RPCModule as unknown as Record; +const mockedAlwaysOn = isMixnetAlwaysOn as jest.Mock; + +function statusPayload(mode: string, socks5Addr?: string): string { + return JSON.stringify( + socks5Addr === undefined + ? { mixnet_mode: mode } + : { mixnet_mode: mode, socks5_addr: socks5Addr }, + ); +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + jest.clearAllMocks(); + mockedAlwaysOn.mockReturnValue(false); + recordMixnetTransportReady(false); +}); + +describe('the refusal text', () => { + it('classifies as a mixnetRefusal: never a server problem, no retry', () => { + expect(classifySendFailure(COVERED_SURFACE_REFUSAL).kind).toBe( + 'mixnetRefusal', + ); + }); +}); + +describe('coveredSurfacePermitted', () => { + it('always permits in the stock flavors', () => { + mockedAlwaysOn.mockReturnValue(false); + recordMixnetTransportReady(false); + expect(coveredSurfacePermitted()).toBe(true); + }); + + it('refuses in an always-on build until the transport is ready', () => { + mockedAlwaysOn.mockReturnValue(true); + recordMixnetTransportReady(false); + expect(coveredSurfacePermitted()).toBe(false); + recordMixnetTransportReady(true); + expect(coveredSurfacePermitted()).toBe(true); + }); +}); + +describe('coordinator readiness and the gate mirror', () => { + // House convention for coordinator tests: fake timers, so the polling + // intervals the coordinator schedules never outlive the test. + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('a ready attach opens the gate; stop closes it fail-closed', async () => { + mockedAlwaysOn.mockReturnValue(true); + mockedBridge.attachMixnet.mockResolvedValue( + statusPayload('ready', '127.0.0.1:1080'), + ); + const coordinator = new MixnetCoordinator( + jest.fn().mockResolvedValue('127.0.0.1:1080'), + () => {}, + ); + + await coordinator.ensureForConnectedSession(); + await flushPromises(); + + expect(coordinator.isReady()).toBe(true); + expect(coveredSurfacePermitted()).toBe(true); + + coordinator.stop(); + expect(coveredSurfacePermitted()).toBe(false); + }); + + it('a failed enable leaves the gate closed', async () => { + mockedAlwaysOn.mockReturnValue(true); + const coordinator = new MixnetCoordinator( + jest.fn().mockRejectedValue(new Error('verifier not initialized')), + () => {}, + ); + + await coordinator.ensureForConnectedSession(); + await flushPromises(); + + expect(coordinator.isReady()).toBe(false); + expect(coveredSurfacePermitted()).toBe(false); + coordinator.stop(); + }); +}); + +describe('auto-recovery (always-on builds only)', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + afterEach(() => { + jest.useRealTimers(); + }); + + it('retries a failed enable after the recovery interval', async () => { + const startTransport = jest + .fn() + .mockRejectedValueOnce(new Error('verifier not initialized')) + .mockResolvedValue('127.0.0.1:1080'); + mockedBridge.attachMixnet.mockResolvedValue( + statusPayload('ready', '127.0.0.1:1080'), + ); + // The steady poll also ticks during the advance; it must keep + // reporting the same state rather than fabricate a failure. + mockedBridge.mixnetModeInfo.mockResolvedValue( + statusPayload('ready', '127.0.0.1:1080'), + ); + const coordinator = new MixnetCoordinator(startTransport, () => {}, true); + + await coordinator.ensureForConnectedSession(); + await flushPromises(); + expect(startTransport).toHaveBeenCalledTimes(1); + expect(coordinator.isReady()).toBe(false); + + await jest.advanceTimersByTimeAsync(RECOVERY_RETRY_MILLIS); + + expect(startTransport).toHaveBeenCalledTimes(2); + expect(coordinator.isReady()).toBe(true); + coordinator.stop(); + }); + + it('never retries on its own in a stock build', async () => { + const startTransport = jest + .fn() + .mockRejectedValue(new Error('shim missing')); + const coordinator = new MixnetCoordinator(startTransport, () => {}); + + await coordinator.ensureForConnectedSession(); + await flushPromises(); + jest.advanceTimersByTime(RECOVERY_RETRY_MILLIS * 3); + await flushPromises(); + + expect(startTransport).toHaveBeenCalledTimes(1); + coordinator.stop(); + }); +}); + +describe('the price fetch gate', () => { + it('refuses without touching the FFI while the gate is closed', async () => { + mockedAlwaysOn.mockReturnValue(true); + recordMixnetTransportReady(false); + + await expect(getZecPrice()).resolves.toEqual({ + kind: 'gateRefusal', + error: COVERED_SURFACE_REFUSAL, + }); + expect(mockedBridge.zecPriceInfo).not.toHaveBeenCalled(); + }); + + it('fetches normally once the transport is ready', async () => { + mockedAlwaysOn.mockReturnValue(true); + recordMixnetTransportReady(true); + mockedBridge.zecPriceInfo.mockResolvedValue( + JSON.stringify({ current_price: 42.5 }), + ); + + await expect(getZecPrice()).resolves.toEqual({ + kind: 'price', + usd: 42.5, + }); + }); +}); diff --git a/__tests__/walletBackend.nymTransport.unit.test.ts b/__tests__/walletBackend.nymTransport.unit.test.ts new file mode 100644 index 000000000..d24044bec --- /dev/null +++ b/__tests__/walletBackend.nymTransport.unit.test.ts @@ -0,0 +1,47 @@ +/** + * Pins the "Always On" flavor gate (CONTEXT.md: the silent alpha APK). + * + * isMixnetAlwaysOn decides whether the app withholds the Mixnet Mode UI + * projection. It must be true only when the native module exports the + * flavor constant as true, and false on every other shape — including the + * module's complete absence (iOS until the Mac-gated step) — so the stock + * flavors and platforms keep their full mixnet UI. + */ + +/** + * Loads a fresh nymTransport against the given NativeModules shape. + * nymTransport captures the native module at import time, so each case + * resets the module registry, mocks react-native to the case's shape, + * and re-imports. + */ +function loadIsMixnetAlwaysOn( + nativeModules: Record, +): () => boolean { + jest.resetModules(); + jest.doMock('react-native', () => ({ NativeModules: nativeModules })); + const { isMixnetAlwaysOn } = require('../app/walletBackend/utils/nymTransport'); + jest.dontMock('react-native'); + return isMixnetAlwaysOn; +} + +describe('isMixnetAlwaysOn', () => { + it('is false where the platform has no transport module (iOS)', () => { + expect(loadIsMixnetAlwaysOn({})()).toBe(false); + }); + + it('is false when the module exports no flavor constant', () => { + expect(loadIsMixnetAlwaysOn({ NymTransportModule: {} })()).toBe(false); + }); + + it('is false in the stock flavors (constant false)', () => { + expect( + loadIsMixnetAlwaysOn({ NymTransportModule: { mixnetAlwaysOn: false } })(), + ).toBe(false); + }); + + it('is true only in the always-on flavor (constant true)', () => { + expect( + loadIsMixnetAlwaysOn({ NymTransportModule: { mixnetAlwaysOn: true } })(), + ).toBe(true); + }); +}); diff --git a/__tests__/walletBackend.walletUtils.unit.test.ts b/__tests__/walletBackend.walletUtils.unit.test.ts index 810e91660..ef057b0f3 100644 --- a/__tests__/walletBackend.walletUtils.unit.test.ts +++ b/__tests__/walletBackend.walletUtils.unit.test.ts @@ -91,22 +91,53 @@ describe('the existence probes contain rejections as false', () => { }); }); -describe('getZecPrice maps outcomes to its documented sentinels', () => { - it.each([ - ['a typed rejection', typedRejection('Indexer', 'oracle down'), -1], - ['an empty resolution', Promise.resolve(''), -2], - ['an { error } body', Promise.resolve('{"error":"no feed"}'), -1], - ['a body without a price', Promise.resolve('{}'), 0], - ['an unparseable body', Promise.resolve('not json'), -2], - ])('%s', async (_case, native, sentinel) => { - bridge.zecPriceInfo.mockReturnValueOnce(native); - const { price } = await getZecPrice(); - expect(price).toBe(sentinel); +describe('getZecPrice discriminates every outcome as a typed variant', () => { + it('a typed rejection carries its variant code through', async () => { + bridge.zecPriceInfo.mockReturnValueOnce( + typedRejection('Indexer', 'oracle down'), + ); + await expect(getZecPrice()).resolves.toEqual({ + kind: 'ffiRejection', + code: 'Indexer', + message: 'oracle down', + }); + }); + + it('an empty resolution is a malformed payload', async () => { + bridge.zecPriceInfo.mockResolvedValueOnce(''); + await expect(getZecPrice()).resolves.toMatchObject({ + kind: 'malformedPayload', + payload: '', + }); + }); + + it('an { error } body is the oracle reporting failure', async () => { + bridge.zecPriceInfo.mockResolvedValueOnce('{"error":"no feed"}'); + await expect(getZecPrice()).resolves.toEqual({ + kind: 'oracleError', + error: 'no feed', + }); + }); + + it('a body without a price is noData, not an error', async () => { + bridge.zecPriceInfo.mockResolvedValueOnce('{}'); + await expect(getZecPrice()).resolves.toEqual({ kind: 'noData' }); + }); + + it('an unparseable body is a malformed payload carrying the payload', async () => { + bridge.zecPriceInfo.mockResolvedValueOnce('not json'); + await expect(getZecPrice()).resolves.toMatchObject({ + kind: 'malformedPayload', + payload: 'not json', + }); }); it('a real price crosses the data channel', async () => { bridge.zecPriceInfo.mockResolvedValueOnce('{"current_price": 42.5}'); - await expect(getZecPrice()).resolves.toEqual({ price: 42.5, error: '' }); + await expect(getZecPrice()).resolves.toEqual({ + kind: 'price', + usd: 42.5, + }); }); }); diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5008eca74..9822d5079 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -158,6 +158,8 @@ android { // MainActivity for releases) keeps prod safe by default — any // future flavor MUST define this bool or compile will fail. resValue("bool", "enforce_privacy_controls", "true") + resValue("bool", "mixnet_always_on", "false") + resValue("string", "default_chain_name", "main") } create("beta") { dimension = "channel" @@ -166,6 +168,32 @@ android { versionName = "2.0.21" // beta override resValue("string", "app_name", "Zingo Beta") resValue("bool", "enforce_privacy_controls", "false") + resValue("bool", "mixnet_always_on", "false") + resValue("string", "default_chain_name", "main") + } + // The silent alpha APKs (CONTEXT.md: "Always On"): Mixnet Mode runs + // with the stock UX — the app never projects a mixnet view, so no + // toggle, banner, or disclaimer renders, and a fail-closed refusal + // surfaces as a plain send error. The transport policy itself is + // unchanged (forced on at wallet load, fail-closed); only the UI + // projection is suppressed, keyed off the mixnet_always_on bool. + // The pair differs only in the chain the first run defaults to; + // distinct application ids let both install beside prod and beta. + create("alwayson") { + dimension = "channel" + applicationIdSuffix = ".AlwaysOn" + resValue("string", "app_name", "Zingo Alpha") + resValue("bool", "enforce_privacy_controls", "false") + resValue("bool", "mixnet_always_on", "true") + resValue("string", "default_chain_name", "main") + } + create("alwaysontest") { + dimension = "channel" + applicationIdSuffix = ".AlwaysOnTest" + resValue("string", "app_name", "Zingo Alpha Test") + resValue("bool", "enforce_privacy_controls", "false") + resValue("bool", "mixnet_always_on", "true") + resValue("string", "default_chain_name", "test") } } diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/NymTransportModule.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/NymTransportModule.kt index 963b963f8..152839746 100644 --- a/android/app/src/main/java/org/ZingoLabs/Zingo/NymTransportModule.kt +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/NymTransportModule.kt @@ -62,6 +62,20 @@ class NymTransportModule internal constructor(reactContext: ReactApplicationCont return "NymTransportModule" } + /** + * `mixnetAlwaysOn` is the "Always On" flavor's flag (CONTEXT.md: the + * silent alpha APK). The app layer reads it once at startup to withhold + * the Mixnet Mode UI projection; the transport policy is flavor-blind. + * Carried as a per-flavor res bool because BuildConfig generation is + * deliberately disabled for build reproducibility. + */ + override fun getConstants(): Map { + return mapOf( + "mixnetAlwaysOn" to + reactApplicationContext.resources.getBoolean(R.bool.mixnet_always_on), + ) + } + companion object { // One proxy per app process, shared across React context reloads. private val handleLock = Any() diff --git a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt index 3d209a15f..6e6c6d4b0 100644 --- a/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt +++ b/android/app/src/main/java/org/ZingoLabs/Zingo/RPCModule.kt @@ -21,6 +21,20 @@ class RPCModule internal constructor(private val reactContext: ReactApplicationC return "RPCModule" } + /** + * `defaultChainName` is the chain a first run of this flavor defaults + * to ("main" everywhere except the testnet alpha flavor). Carried as a + * per-flavor res string because BuildConfig generation is deliberately + * disabled for build reproducibility; the app reads it only when no + * persisted server setting exists yet. + */ + override fun getConstants(): Map { + return mapOf( + "defaultChainName" to + applicationContext.resources.getString(R.string.default_chain_name), + ) + } + private fun getDocumentDirectory(): String { return applicationContext.filesDir.absolutePath } diff --git a/app/LoadedApp/LoadedApp.tsx b/app/LoadedApp/LoadedApp.tsx index acde18c0e..8cba730f5 100644 --- a/app/LoadedApp/LoadedApp.tsx +++ b/app/LoadedApp/LoadedApp.tsx @@ -26,7 +26,7 @@ import { deactivateKeepAwake, } from '@sayem314/react-native-keep-awake'; -import WalletBackend, { fetchWallet } from '../walletBackend'; +import WalletBackend, { fetchWalletOutcome } from '../walletBackend'; import { changeServer, doSave, @@ -126,7 +126,11 @@ import { INITIAL_MIXNET_VIEW, MixnetView, } from '../walletBackend/transforms/mixnetPresenter'; -import { startMixnetTransport } from '../walletBackend/utils/nymTransport'; +import { + isMixnetAlwaysOn, + startMixnetTransport, +} from '../walletBackend/utils/nymTransport'; +import { flavorDefaultChainName } from '../utils/flavor'; import { RPCPerformanceLevelEnum } from '../walletBackend/enums/RPCPerformanceLevelEnum'; import { AddressList } from '../../components/AddressList'; import ValueTransferDetail from '../../components/History/components/ValueTransferDetail'; @@ -191,9 +195,17 @@ type LoadedAppProps = { toggleTheme: (mode: ModeEnum) => void; }; +// The flavor's chain decides the fallback default server (the testnet +// alpha flavor falls back to the testnet default); persisted settings +// always override this. +const flavorDefaultServer = + serverUris(() => {}).find( + s => s.chainName === flavorDefaultChainName() && s.default, + ) ?? serverUris(() => {})[0]; + const SERVER_DEFAULT_0: ServerType = { - uri: serverUris(() => {})[0].uri, - chainName: serverUris(() => {})[0].chainName, + uri: flavorDefaultServer.uri, + chainName: flavorDefaultServer.chainName, } as ServerType; export default function LoadedApp(props: LoadedAppProps) { @@ -837,8 +849,11 @@ export class LoadedAppClass extends Component< // Mixnet Mode: fail-closed initial view where the policy runs // (Android); null where the platform transport has not landed yet // (iOS until the Mac-gated step), which leaves the send gate open. + // The "always on" flavor (the silent alpha APK) also starts null and + // stays null — publications are withheld below — so the stock UI + // renders while the forced-on transport policy runs unchanged. mixnetView: - Platform.OS === GlobalConst.platformOSandroid + Platform.OS === GlobalConst.platformOSandroid && !isMixnetAlwaysOn() ? INITIAL_MIXNET_VIEW : null, disableMixnet: this.disableMixnet, @@ -873,9 +888,25 @@ export class LoadedAppClass extends Component< onZingolibVersionChanged: this.setZingolibVersion, onBirthdayChanged: this.setBirthday, onError: this.setLastError, - onMixnetViewChanged: this.setMixnetView, + // In the "always on" flavor the view is withheld from the context, so + // every mixnet surface (Settings section, banners, the Send gate) + // stays on its stock rendering and a fail-closed refusal arrives as a + // plain typed send error. The coordinator still runs the forced-on + // policy, and the view lands in the dev log instead — the silent + // flavors keep the UI stock, not the diagnostics. + onMixnetViewChanged: isMixnetAlwaysOn() + ? (view: MixnetView) => { + console.log( + 'mixnet (silent):', + view.statusKey, + view.socks5Addr ?? '', + view.narration ?? '', + ); + } + : this.setMixnetView, startMixnetTransport: startMixnetTransport, mixnetSupported: Platform.OS === GlobalConst.platformOSandroid, + mixnetAlwaysOn: isMixnetAlwaysOn(), readOnly: props.readOnly, server: props.server, performanceLevel: props.performanceLevel, @@ -1901,9 +1932,21 @@ export class LoadedAppClass extends Component< if (!value) { await removeRecoveryWalletInfo(); } else { - const wallet = await fetchWallet(this.state.readOnly); - if (wallet) { - await createUpdateRecoveryWalletInfo(wallet); + const outcome = await fetchWalletOutcome(this.state.readOnly); + if (outcome.kind === 'complete') { + await createUpdateRecoveryWalletInfo(outcome.wallet); + } else { + // Nothing was stored, so the setting must not claim otherwise: + // revert the toggle and tell the user instead of silently leaving + // an enabled switch with no backup behind it. + await SettingsFileImpl.writeSettings( + SettingsNameEnum.recoveryWalletInfoOnDevice, + false, + ); + this.setState({ recoveryWalletInfoOnDevice: false }); + this.addLastSnackbar( + this.state.translate('loadedapp.recoveryinfo-error') as string, + ); } } }; diff --git a/app/LoadingApp/LoadingApp.tsx b/app/LoadingApp/LoadingApp.tsx index 13546bf4e..418da6208 100644 --- a/app/LoadingApp/LoadingApp.tsx +++ b/app/LoadingApp/LoadingApp.tsx @@ -76,6 +76,7 @@ import BackgroundFileImpl from '../../components/Background'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { createAlert } from '../createAlert'; import { getZingoVersion, substituteZingoName } from '../utils/ZingoAppData'; +import { flavorDefaultChainName } from '../utils/flavor'; import Utils from '../utils'; import { RPCWalletKindType } from '../walletBackend/types/RPCWalletKindType'; import Toast from 'react-native-toast-message'; @@ -84,6 +85,7 @@ import { RPCSeedType } from '../walletBackend/types/RPCSeedType'; import Launching from './components/Launching'; import simpleBiometrics from '../simpleBiometrics'; import selectingServer from '../selectingServer'; +import { serverProbeVerdict } from '../serverProbeVerdict'; import { isEqual } from 'lodash'; import { createUpdateRecoveryWalletInfo, @@ -117,9 +119,17 @@ type LoadingAppProps = { toggleTheme: (mode: ModeEnum) => void; }; +// The flavor's chain decides the first-run default server (the testnet +// alpha flavor starts on the testnet default); a persisted server setting +// always overrides this. +const flavorDefaultServer: ServerUrisType = + serverUris(() => {}).find( + (s: ServerUrisType) => s.chainName === flavorDefaultChainName() && s.default, + ) ?? serverUris(() => {})[0]; + const SERVER_DEFAULT_0: ServerType = { - uri: serverUris(() => {})[0].uri, - chainName: serverUris(() => {})[0].chainName, + uri: flavorDefaultServer.uri, + chainName: flavorDefaultServer.chainName, } as ServerType; const activationHeight = { @@ -539,7 +549,7 @@ export class LoadingAppClass extends Component< walletExists: false, hasBackupWallet: false, customServerUri: '', - customServerChainName: ChainNameEnum.mainChainName, + customServerChainName: flavorDefaultChainName(), customServerOffline: false, customServerAuto: false, customServerCustom: false, @@ -1019,8 +1029,12 @@ export class LoadingAppClass extends Component< ), ); let fasterServer: ServerType = {} as ServerType; - if (server && server.latency) { - fasterServer = { uri: server.uri, chainName: server.chainName }; + const bestVerdict = serverProbeVerdict(server); + if (bestVerdict.kind === 'reachable') { + fasterServer = { + uri: bestVerdict.server.uri, + chainName: bestVerdict.server.chainName, + }; } else { fasterServer = actualServer; // likely here there is a internet/wifi conection problem @@ -1105,11 +1119,7 @@ export class LoadingAppClass extends Component< obsolete: false, } as ServerUrisType; const serverChecked = await selectingServer([s]); - if (serverChecked && serverChecked.latency) { - return true; - } else { - return false; - } + return serverProbeVerdict(serverChecked).kind === 'reachable'; }; walletErrorHandle = async ( @@ -1393,7 +1403,7 @@ export class LoadingAppClass extends Component< obsolete: false, } as ServerUrisType; const serverChecked = await selectingServer([cs]); - if (!serverChecked || !serverChecked.latency) { + if (serverProbeVerdict(serverChecked).kind !== 'reachable') { this.addLastSnackbar( (this.state.translate('loadedapp.changeservernew-error') as string) + uri, diff --git a/app/context/optionsPanel.tsx b/app/context/optionsPanel.tsx index 41d743a73..4b447c630 100644 --- a/app/context/optionsPanel.tsx +++ b/app/context/optionsPanel.tsx @@ -48,14 +48,12 @@ export const OptionsPanelProvider: React.FC<{ children: React.ReactNode }> = ({ // without threading the context through props. useEffect(() => { registerToggle(toggle); - registerOpen(open); registerClose(close); return () => { registerToggle(null); - registerOpen(null); registerClose(null); }; - }, [toggle, open, close]); + }, [toggle, close]); const value = useMemo( () => ({ isOpen, open, close, toggle }), @@ -79,15 +77,11 @@ export const useOptionsPanel = (): OptionsPanelContextValue => // --------------------------------------------------------------------------- type Listener = (() => void) | null; let _toggleListener: Listener = null; -let _openListener: Listener = null; let _closeListener: Listener = null; const registerToggle = (fn: Listener) => { _toggleListener = fn; }; -const registerOpen = (fn: Listener) => { - _openListener = fn; -}; const registerClose = (fn: Listener) => { _closeListener = fn; }; @@ -107,13 +101,6 @@ export const toggleOptionsPanel = (): void => { } _toggleListener(); }; -export const openOptionsPanel = (): void => { - if (!_openListener) { - warnUnwired('open'); - return; - } - _openListener(); -}; export const closeOptionsPanel = (): void => { if (!_closeListener) { warnUnwired('close'); diff --git a/app/hooks/useBottomSheetBackHandler.tsx b/app/hooks/useBottomSheetBackHandler.tsx index 2c5929f77..6256471c6 100644 --- a/app/hooks/useBottomSheetBackHandler.tsx +++ b/app/hooks/useBottomSheetBackHandler.tsx @@ -15,7 +15,7 @@ import { useBottomSheetModal } from '@gorhom/bottom-sheet'; * Mount once per BottomSheetModalProvider via the * wrapper, since the hook reads from the modal context. */ -export function useBottomSheetBackHandler() { +function useBottomSheetBackHandler() { const { dismiss } = useBottomSheetModal(); useEffect(() => { diff --git a/app/hooks/useTrickleProgress.ts b/app/hooks/useTrickleProgress.ts index 67917e2ba..a2c25989b 100644 --- a/app/hooks/useTrickleProgress.ts +++ b/app/hooks/useTrickleProgress.ts @@ -1,11 +1,8 @@ import { useCallback, useEffect, useRef } from 'react'; import { Animated, Easing } from 'react-native'; -import useReduceMotion from './useReduceMotion'; - -export type TrickleProgress = { - // The bar's value, 0..1. Interpolate to a transform, not a width: the bar - // runs on the native driver and cannot animate layout. +type TrickleProgress = { + // The bar's value, 0..1. Interpolate to a width percentage. progress: Animated.Value; // Push the ceiling forward as real progress lands. Clamped to 0.985 so the // bar never looks finished before finish() is called. @@ -26,41 +23,36 @@ export type TrickleProgress = { // the splitting screen renders the same feel from coarser events. const useTrickleProgress = (initialCeiling: number = 0.08): TrickleProgress => { const progressAnim = useRef(new Animated.Value(0)).current; - // Where the bar is headed. Each step runs to completion, so the target it - // was handed is where it ends up. Reading the value back through a listener - // instead would put a JS callback on every frame of a native animation, - // which is the whole cost this bar is trying to avoid. - const targetRef = useRef(0); + const currentRef = useRef(0); // Small head start so the bar moves from mount. const ceilingRef = useRef(initialCeiling); // A ref, not state, so the animation loop reads the latest value without // re-subscribing. const doneRef = useRef(false); - const reduceMotion = useReduceMotion(); + + useEffect(() => { + const id = progressAnim.addListener(({ value }) => { + currentRef.current = value; + }); + return () => progressAnim.removeListener(id); + }, [progressAnim]); // The trickle driver: chains short eased steps, each closing a fraction of // the gap to the ceiling, so the bar is perpetually moving. Stops once // stop()/finish() marks the work done, or on unmount. useEffect(() => { - if (reduceMotion) { - // Drop the perpetual creep and let the bar sit where real progress put - // it. The reading survives, the movement does not. - progressAnim.setValue(Math.min(1, ceilingRef.current)); - return; - } let cancelled = false; const tick = () => { if (cancelled || doneRef.current) { return; } - const target = - targetRef.current + (ceilingRef.current - targetRef.current) * 0.12; - targetRef.current = target; + const cur = currentRef.current; + const target = cur + (ceilingRef.current - cur) * 0.12; Animated.timing(progressAnim, { toValue: target, duration: 360, easing: Easing.linear, - useNativeDriver: true, + useNativeDriver: false, }).start(({ finished }) => { if (finished && !cancelled && !doneRef.current) { tick(); @@ -72,25 +64,15 @@ const useTrickleProgress = (initialCeiling: number = 0.08): TrickleProgress => { cancelled = true; progressAnim.stopAnimation(); }; - }, [progressAnim, reduceMotion]); + }, [progressAnim]); - const setCeiling = useCallback( - (ceiling: number) => { - ceilingRef.current = Math.min(0.985, ceiling); - if (reduceMotion) { - targetRef.current = ceilingRef.current; - progressAnim.setValue(ceilingRef.current); - } - }, - [progressAnim, reduceMotion], - ); + const setCeiling = useCallback((ceiling: number) => { + ceilingRef.current = Math.min(0.985, ceiling); + }, []); const stop = useCallback(() => { doneRef.current = true; - // Halt the step already in flight. Without this the bar keeps advancing - // for up to another 360ms after the work it reports has stopped. - progressAnim.stopAnimation(); - }, [progressAnim]); + }, []); const finish = useCallback( (onFinished?: () => void) => { @@ -98,13 +80,6 @@ const useTrickleProgress = (initialCeiling: number = 0.08): TrickleProgress => { return; } doneRef.current = true; - if (reduceMotion) { - progressAnim.setValue(1); - if (onFinished) { - onFinished(); - } - return; - } // Supersedes any in-flight trickle step on the same Animated.Value. If // the component unmounts mid-glide, the trickle cleanup's stopAnimation // lands finished=false and the callback is skipped. @@ -112,14 +87,14 @@ const useTrickleProgress = (initialCeiling: number = 0.08): TrickleProgress => { toValue: 1, duration: 500, easing: Easing.out(Easing.cubic), - useNativeDriver: true, + useNativeDriver: false, }).start(({ finished }) => { if (finished && onFinished) { onFinished(); } }); }, - [progressAnim, reduceMotion], + [progressAnim], ); return { progress: progressAnim, setCeiling, stop, finish }; diff --git a/app/notifications/reminders.ts b/app/notifications/reminders.ts index 9bae163eb..d0f24e214 100644 --- a/app/notifications/reminders.ts +++ b/app/notifications/reminders.ts @@ -69,7 +69,7 @@ export async function armBatchReminders( } } -export async function cancelBatchReminders(): Promise { +async function cancelBatchReminders(): Promise { const ids = await notifee.getTriggerNotificationIds(); const ours = ids.filter(id => id.startsWith(REMINDER_PREFIX)); if (ours.length > 0) { diff --git a/app/serverProbeVerdict.ts b/app/serverProbeVerdict.ts new file mode 100644 index 000000000..f5ae2b87c --- /dev/null +++ b/app/serverProbeVerdict.ts @@ -0,0 +1,24 @@ +/** + * Pure verdict on a selectingServer() probe result. + * + * selectingServer resolves a server only when its probe actually answered, + * so any resolved probe describes a reachable server — including one that + * measured 0 ms, which happens when the probe's two Date.now() calls land + * in the same millisecond against a localhost/LAN regtest server. The + * strict `latency !== null` read is therefore load-bearing: truthiness + * would fold that 0 into the null "no measurement" state. + */ +import { ServerUrisType } from './AppState'; + +export type ServerProbeVerdict = + | { kind: 'reachable'; server: ServerUrisType; latencyMs: number } + | { kind: 'unreachable' }; + +export const serverProbeVerdict = ( + probe: ServerUrisType | null, +): ServerProbeVerdict => { + if (probe !== null && probe.latency !== null) { + return { kind: 'reachable', server: probe, latencyMs: probe.latency }; + } + return { kind: 'unreachable' }; +}; diff --git a/app/showConfirm.ts b/app/showConfirm.ts index 18364813b..49410b5dd 100644 --- a/app/showConfirm.ts +++ b/app/showConfirm.ts @@ -5,7 +5,7 @@ * BottomSheetModalProvider. The sheet is registered exactly once per * provider; calling showConfirm before mount no-ops (warns in dev). */ -export type ConfirmButtonStyle = 'default' | 'cancel' | 'destructive' | 'ghost'; +type ConfirmButtonStyle = 'default' | 'cancel' | 'destructive' | 'ghost'; export type ConfirmButton = { text: string; diff --git a/app/translations/en.json b/app/translations/en.json index 34733d30e..5dd33024e 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -156,6 +156,7 @@ "support": "{name} support email", "changeservernew-error": "Error trying to change the server to the new one: ", "readingwallet-error": "Error trying to read the wallet with the new server:", + "recoveryinfo-error": "Could not read the wallet recovery information, so nothing was stored on this device. The setting has been turned back off.", "differentchain-error": "You are changing to a server with a different chain, you will no longer have access to this wallet without the seed phrase.", "serverchain-mismatch": "The server reports a different chain than the one you selected. Check the chain selector and try again.", "changingwallet-label": "Error changing Wallet", @@ -631,6 +632,7 @@ }, "verification-success": "This address belongs to you and is safe for receiving funds.", "verification-failure": "This address does not belong to you.", + "verification-unavailable": "The verification could not be completed. Please try again.", "add-tag": "Add tag" }, "history": { diff --git a/app/translations/es.json b/app/translations/es.json index fe8fd2c36..232711ce0 100644 --- a/app/translations/es.json +++ b/app/translations/es.json @@ -156,6 +156,7 @@ "support": "Email de soporte de {name}", "changeservernew-error": "Error al intentar cambiar al Servidor nuevo: ", "readingwallet-error": "Error al intentar leer el monedero activo usando el Servidor nuevo:", + "recoveryinfo-error": "No se pudo leer la información de recuperación del monedero, así que no se guardó nada en este dispositivo. La opción se ha desactivado de nuevo.", "differentchain-error": "Estás cambiando a un servidor con diferente tipo de cadena, ya no tendrás acceso a este monedero sin tu frase semilla.", "serverchain-mismatch": "El servidor reporta una cadena diferente a la seleccionada. Revisa el selector de cadena e inténtalo de nuevo.", "changingwallet-label": "Error cambiando de Monedero", @@ -631,6 +632,7 @@ }, "verification-success": "Esta dirección te pertenece y es segura para recibir fondos.", "verification-failure": "Esta dirección no te pertenece.", + "verification-unavailable": "No se pudo completar la verificación. Inténtalo de nuevo.", "add-tag": "Añadir etiqueta" }, "history": { diff --git a/app/translations/pt.json b/app/translations/pt.json index a46884f06..7ecfe3740 100644 --- a/app/translations/pt.json +++ b/app/translations/pt.json @@ -156,6 +156,7 @@ "support": "E-mail de suporte do {name}", "changeservernew-error": "Error ao tentar alterar para um novo servidor: ", "readingwallet-error": "Error ao tentar ler a carteira com o novo servidor:", + "recoveryinfo-error": "Não foi possível ler as informações de recuperação da carteira, portanto nada foi armazenado neste dispositivo. A configuração foi desativada novamente.", "differentchain-error": "Você está mudando para um servidor com uma rede diferente, você não terá mais acesso a essa carteira sem suas frases de recuperação.", "serverchain-mismatch": "O servidor informa uma rede diferente da selecionada. Verifique o seletor de rede e tente novamente.", "changingwallet-label": "Error ao Alterar a Carteira", @@ -631,6 +632,7 @@ }, "verification-success": "Este endereço pertence a você e é seguro para receber fundos.", "verification-failure": "Este endereço não pertence a você.", + "verification-unavailable": "Não foi possível concluir a verificação. Tente novamente.", "add-tag": "Adicionar etiqueta" }, "history": { diff --git a/app/translations/ru.json b/app/translations/ru.json index 8708e042a..2de805569 100644 --- a/app/translations/ru.json +++ b/app/translations/ru.json @@ -156,6 +156,7 @@ "support": "Email поддержки {name}", "changeservernew-error": "Error при попытке изменить сервер на другой: ", "readingwallet-error": "Error при попытке прочитать кошелёк с новым сервером:", + "recoveryinfo-error": "Не удалось прочитать информацию для восстановления кошелька, поэтому на этом устройстве ничего не сохранено. Настройка снова отключена.", "differentchain-error": "ВНИМАНИЕ! Если вы перейдёте на сервер в другой сети, у вас больше не будет доступа к данному кошельку без наличия начальной фразы.", "serverchain-mismatch": "Сервер сообщает о другой сети, отличной от выбранной. Проверьте селектор сети и попробуйте снова.", "changingwallet-label": "Error при смене кошелька", @@ -631,6 +632,7 @@ }, "verification-success": "Этот адрес принадлежит вам и безопасен для получения средств.", "verification-failure": "Этот адрес не принадлежит вам.", + "verification-unavailable": "Не удалось выполнить проверку. Пожалуйста, попробуйте ещё раз.", "add-tag": "Добавить тег" }, "history": { diff --git a/app/translations/tr.json b/app/translations/tr.json index 918ef8e06..8b3b497d9 100644 --- a/app/translations/tr.json +++ b/app/translations/tr.json @@ -156,6 +156,7 @@ "support": "{name} destek mail", "changeservernew-error": "Error Sunucuyu yenisiyle değiştirmeye çalışırken bir hata oluştu ", "readingwallet-error": "Error Yeni sunucuyla cüzdanı okuma girişiminde hata oluştu:", + "recoveryinfo-error": "Cüzdan kurtarma bilgileri okunamadı, bu nedenle bu cihaza hiçbir şey kaydedilmedi. Ayar yeniden kapatıldı.", "differentchain-error": "Farklı bir zincire sahip bir sunucuya geçiyorsunuz, bu kurtarma ifadesi olmadan bu cüzdana artık erişiminiz olmayacak.", "serverchain-mismatch": "Sunucu seçtiğinizden farklı bir zincir bildiriyor. Zincir seçiciyi kontrol edip tekrar deneyin.", "changingwallet-label": "Error Cüzdan değiştirilirken bir hata oluştu", @@ -631,6 +632,7 @@ }, "verification-success": "Bu adres size aittir ve fon almak için güvenlidir.", "verification-failure": "Bu adres size ait değil.", + "verification-unavailable": "Doğrulama tamamlanamadı. Lütfen tekrar deneyin.", "add-tag": "Etiket ekle" }, "history": { diff --git a/app/types/NavigationTypes.ts b/app/types/NavigationTypes.ts index 0828a6225..55a8fd1ce 100644 --- a/app/types/NavigationTypes.ts +++ b/app/types/NavigationTypes.ts @@ -104,17 +104,17 @@ export type AppDrawerParamList = { [RouteEnum.Seed]: SeedNavigationState | undefined; }; -export type AddressBookNavigationState = { +type AddressBookNavigationState = { currentAddress: string; routeStack: RouteEnum; }; -export type AddressListNavigationState = { +type AddressListNavigationState = { addressKind: AddressKindEnum; setIndex: (n: number) => void; }; -export type ScannerAddressNavigationState = { +type ScannerAddressNavigationState = { setAddress: (a: string) => void; active: boolean; // When true the scanner returns the scanned string verbatim — no `zcash:` @@ -123,19 +123,19 @@ export type ScannerAddressNavigationState = { raw?: boolean; }; -export type ScannerUfvkNavigationState = { +type ScannerUfvkNavigationState = { setUfvkText: (k: string) => void; active: boolean; }; -export type ValueTransferDetailNavigationState = { +type ValueTransferDetailNavigationState = { index: number; vt: ValueTransferType; valueTransfersSliced: ValueTransferType[]; totalLength: number; }; -export type ConfirmNavigationState = { +type ConfirmNavigationState = { calculatedFee: number; parseAddressInfoJSON: RPCParseAddressType; donationAmount: number; @@ -151,10 +151,10 @@ export type ConfirmNavigationState = { nym: boolean; }; -export type UfvkNavigationState = { +type UfvkNavigationState = { action: UfvkActionEnum; }; -export type SeedNavigationState = { +type SeedNavigationState = { action: SeedActionEnum; }; diff --git a/app/utils/Utils.ts b/app/utils/Utils.ts index 26a2bff11..c24a1a284 100644 --- a/app/utils/Utils.ts +++ b/app/utils/Utils.ts @@ -1,4 +1,8 @@ import { getNumberFormatSettings } from 'react-native-localize'; +import { + parseNumberFloatToStringLocale, + parseStringLocaleToNumberFloat, +} from './localeNumber'; import { format as dateFnsFormat, differenceInMinutes, @@ -188,22 +192,14 @@ export default class Utils { } static parseStringLocaleToNumberFloat(stringValue: string): number { - const { decimalSeparator } = getNumberFormatSettings(); - - return Number( - stringValue.replace(new RegExp(`\\${decimalSeparator}`), '.'), - ); + return parseStringLocaleToNumberFloat(stringValue); } static parseNumberFloatToStringLocale( numberValue: number, toFixed: number, ): string { - const { decimalSeparator } = getNumberFormatSettings(); - - let stringValue = numberValue.toFixed(toFixed); - - return stringValue.replace(new RegExp('\\.'), `${decimalSeparator}`); + return parseNumberFloatToStringLocale(numberValue, toFixed); } static getBlockExplorerTxIDURL( diff --git a/app/utils/flavor.ts b/app/utils/flavor.ts new file mode 100644 index 000000000..f1a8a82c6 --- /dev/null +++ b/app/utils/flavor.ts @@ -0,0 +1,19 @@ +import { NativeModules } from 'react-native'; + +import { ChainNameEnum } from '../AppState'; + +/** + * The chain a first run of this build flavor defaults to. The testnet alpha + * flavor exports "test" through RPCModule's constants; every other flavor — + * and any platform whose native module predates the constant — defaults to + * mainnet. Only the no-persisted-settings path consults this: once a server + * setting exists, it always wins. + */ +export function flavorDefaultChainName(): ChainNameEnum { + const constant: unknown = ( + NativeModules.RPCModule as { defaultChainName?: string } | undefined + )?.defaultChainName; + return constant === ChainNameEnum.testChainName + ? ChainNameEnum.testChainName + : ChainNameEnum.mainChainName; +} diff --git a/app/utils/listSelection.ts b/app/utils/listSelection.ts new file mode 100644 index 000000000..b12a264b7 --- /dev/null +++ b/app/utils/listSelection.ts @@ -0,0 +1,38 @@ +/** + * Pure derivation of "which item of this list is designated, if any". + * + * Screens store a designated index as `number | null` next to the list it + * points into (Receive's address lists, the address book's detail item), + * and two facts make the raw pair treacherous: some writers encode an + * *empty* list as index 0, and some encode "no item" as -1. A non-null + * index therefore never proves an item exists. This function is total: + * every (list, storedIndex) pair maps to exactly one named state. + * + * - `noSelection`: nothing is designated — the index is null, a negative + * sentinel, or points past the end of the current list. A stale index is + * deliberately NOT clamped: guessing a different item than the user + * chose (an edit sheet opening on the wrong contact) is worse than + * designating none. + * - `empty`: an index is stored but the list has no items to designate. + * - `selected`: `item` really is `list[index]`. + */ +export type ListSelection = + | { readonly kind: 'noSelection' } + | { readonly kind: 'empty' } + | { readonly kind: 'selected'; readonly item: A; readonly index: number }; + +export function deriveListSelection( + list: A[], + storedIndex: number | null, +): ListSelection { + if (storedIndex === null || storedIndex < 0) { + return { kind: 'noSelection' }; + } + if (list.length === 0) { + return { kind: 'empty' }; + } + if (storedIndex >= list.length) { + return { kind: 'noSelection' }; + } + return { kind: 'selected', item: list[storedIndex], index: storedIndex }; +} diff --git a/app/utils/localeNumber.ts b/app/utils/localeNumber.ts new file mode 100644 index 000000000..7a59a319a --- /dev/null +++ b/app/utils/localeNumber.ts @@ -0,0 +1,24 @@ +/** + * Locale-aware number parsing and formatting, as a leaf module: its only + * dependency is the device's number-format settings, so pure form logic + * (and its unit tests) can use it without dragging in the native bridge + * that the full Utils barrel imports. + */ +import { getNumberFormatSettings } from 'react-native-localize'; + +export function parseStringLocaleToNumberFloat(stringValue: string): number { + const { decimalSeparator } = getNumberFormatSettings(); + + return Number(stringValue.replace(new RegExp(`\\${decimalSeparator}`), '.')); +} + +export function parseNumberFloatToStringLocale( + numberValue: number, + toFixed: number, +): string { + const { decimalSeparator } = getNumberFormatSettings(); + + const stringValue = numberValue.toFixed(toFixed); + + return stringValue.replace(new RegExp('\\.'), `${decimalSeparator}`); +} diff --git a/app/walletBackend/WalletBackend.ts b/app/walletBackend/WalletBackend.ts index 9f94d1ad3..67ece8cf9 100644 --- a/app/walletBackend/WalletBackend.ts +++ b/app/walletBackend/WalletBackend.ts @@ -19,6 +19,7 @@ import { WalletBackendConfig } from './config/WalletBackendConfig'; import { RPCPerformanceLevelEnum } from './enums/RPCPerformanceLevelEnum'; import { DataService } from './modules/DataService'; import { MixnetCoordinator } from './modules/MixnetCoordinator'; +import { COVERED_SURFACE_REFUSAL } from './utils/mixnetGate'; import { SyncCoordinator } from './modules/SyncCoordinator'; import { TransactionService } from './modules/TransactionService'; import { WalletLifecycleService } from './modules/WalletLifecycleService'; @@ -43,6 +44,7 @@ export default class WalletBackend { this.mixnetCoordinator = new MixnetCoordinator( config.startMixnetTransport, config.onMixnetViewChanged, + config.mixnetAlwaysOn, ); // Wire the sync-restart callback after SyncCoordinator exists this.dataService.onSyncError = async () => { @@ -93,6 +95,21 @@ export default class WalletBackend { // Transactions async sendTransaction(sendJson: Array): Promise { + // The always-on flavors' fail-closed gate (CONTEXT.md: Fail-closed). + // Those builds withhold the mixnet UI, so the view-level send gate the + // stock flavors rely on never renders — and a failed enable leaves the + // wallet's mode at `off`, where zingolib itself would broadcast over + // clearnet. The backend therefore refuses here unless the transport is + // `ready`. The message carries the "Nym mixnet" marker so + // classifySendFailure files it as mixnetRefusal: never a server + // problem, never retried elsewhere. + if ( + this.config.mixnetSupported && + this.config.mixnetAlwaysOn && + !this.mixnetCoordinator.isReady() + ) { + throw new Error(COVERED_SURFACE_REFUSAL); + } return this.transactionService.sendTransaction(sendJson); } diff --git a/app/walletBackend/config/WalletBackendConfig.ts b/app/walletBackend/config/WalletBackendConfig.ts index dc416dc4c..5cc54f060 100644 --- a/app/walletBackend/config/WalletBackendConfig.ts +++ b/app/walletBackend/config/WalletBackendConfig.ts @@ -53,6 +53,13 @@ export type WalletBackendConfig = { * never started and no mixnet view is ever published. */ mixnetSupported: boolean; + /** + * Whether this build is an always-on (silent alpha) flavor. True enables + * the backend-layer fail-closed send gate — sends refuse unless the + * transport is `ready` — and the coordinator's auto-recovery loop, both + * of which stand in for the mixnet UI those flavors withhold. + */ + mixnetAlwaysOn: boolean; /** i18n helper — must be bound to the active locale in the consumer. */ translate: (key: string) => TranslateType; /** Prevent device sleep while true (e.g. during active sync/send). */ diff --git a/app/walletBackend/ffi.ts b/app/walletBackend/ffi.ts index 1cc994e1c..9d8ce052a 100644 --- a/app/walletBackend/ffi.ts +++ b/app/walletBackend/ffi.ts @@ -17,6 +17,7 @@ const FFI_ERROR_CODES = [ 'Sync', 'Rescan', 'Read', + 'Mixnet', 'Send', 'Shield', 'InvalidInput', @@ -73,3 +74,48 @@ export async function callFfi( return { ok: false, error: toFfiError(rejection) }; } } + +/** + * The transport-level decode every JSON-carrying native surface shares, + * written once: a settled FfiResult either yields parsed JSON or exactly + * one named transport failure. Domain interpreters (price, wallet fetch, + * address check) start from the `json` arm and add only their own payload + * validation — none of them re-implements the rejection / empty / + * unparseable triage. `raw` travels with the JSON so a consumer that + * rejects the payload for domain reasons can still report it verbatim. + */ +export type FfiJsonDecode = + | { + readonly kind: 'ffiRejection'; + readonly code: FfiErrorCode; + readonly message: string; + } + | { readonly kind: 'emptyPayload' } + | { + readonly kind: 'malformedPayload'; + readonly payload: string; + readonly detail: string; + } + | { readonly kind: 'json'; readonly value: unknown; readonly raw: string }; + +export function decodeFfiJson(result: FfiResult): FfiJsonDecode { + if (!result.ok) { + return { + kind: 'ffiRejection', + code: result.error.code, + message: result.error.message, + }; + } + if (!result.value) { + return { kind: 'emptyPayload' }; + } + try { + return { kind: 'json', value: JSON.parse(result.value), raw: result.value }; + } catch (error) { + return { + kind: 'malformedPayload', + payload: result.value, + detail: String(error), + }; + } +} diff --git a/app/walletBackend/index.ts b/app/walletBackend/index.ts index 2d22503ed..47d08f535 100644 --- a/app/walletBackend/index.ts +++ b/app/walletBackend/index.ts @@ -7,12 +7,12 @@ */ import WalletBackend from './WalletBackend'; -export type { FfiError, FfiErrorCode, FfiResult } from './ffi'; -export type { StartMigrationRoute } from './utils/migrationRouting'; +export type { FfiResult } from './ffi'; +export type { ZecPriceOutcome } from './utils/walletUtils'; +export { matchZecPriceOutcome } from './utils/walletUtils'; export { routeStartMigration } from './utils/migrationRouting'; export { scanInProgress } from './utils/syncProgress'; export { - cancelIronwoodMigration, changeServer, checkMyAddress, continueNoteSplitting, @@ -20,12 +20,12 @@ export { createNewUnifiedAddress, createNewWallet, doSave, - doSaveBackup, drainOrchard, drainStatus, executeDueParts, executeDuePartsStatus, fetchWallet, + fetchWalletOutcome, getBalanceInfo, getDonationAddress, getLatestBlockServerInfo, diff --git a/app/walletBackend/modules/MixnetCoordinator.ts b/app/walletBackend/modules/MixnetCoordinator.ts index a31146670..442d7ca74 100644 --- a/app/walletBackend/modules/MixnetCoordinator.ts +++ b/app/walletBackend/modules/MixnetCoordinator.ts @@ -35,6 +35,7 @@ import { getMixnetBootstrapDetail, getMixnetStatus, } from '../utils/mixnetUtils'; +import { recordMixnetTransportReady } from '../utils/mixnetGate'; /** * Starts the platform-hosted mixnet transport and yields its local SOCKS5 @@ -57,11 +58,19 @@ export const BOOTSTRAP_POLL_MILLIS = 2_000; /** How often the coordinator polls outside of bootstrapping. */ export const STEADY_POLL_MILLIS = 30_000; +/** + * How long an auto-recovering coordinator waits after a failure, `died`, or + * unconsented `off` before starting the transport afresh. + */ +export const RECOVERY_RETRY_MILLIS = 60_000; + export class MixnetCoordinator { private readonly startTransport: StartMixnetTransport; private readonly onChange: (view: MixnetView) => void; + private readonly autoRecover: boolean; private pollTimerID?: ReturnType; + private recoveryTimerID?: ReturnType; private pollLock: boolean = false; private lastStatus: MixnetStatusReport | null = null; // The consent bit belongs to the coordinator, not the wallet: the wallet @@ -69,12 +78,21 @@ export class MixnetCoordinator { // session, and only the former is consent (#1226). private consent: ClearnetConsent = 'none'; + /** + * `autoRecover` is the always-on flavors' recovery path: with the mixnet + * UI withheld there is no human re-enable, so a failure, a `died` + * transport, or an unconsented `off` schedules a fresh + * [`ensureForConnectedSession`] after [`RECOVERY_RETRY_MILLIS`]. Stock + * builds leave it false: there, recovery is the user's deliberate act. + */ constructor( startTransport: StartMixnetTransport, onChange: (view: MixnetView) => void, + autoRecover: boolean = false, ) { this.startTransport = startTransport; this.onChange = onChange; + this.autoRecover = autoRecover; } /** @@ -90,6 +108,9 @@ export class MixnetCoordinator { const socks5Addr = await this.startTransport(); this.publish(await attachMixnet(socks5Addr)); } catch (thrown: unknown) { + // Dev diagnostic: the failure view the screens render carries no + // detail, and the silent alpha flavors render nothing at all. + console.log('mixnet enable failed:', thrown); this.publish({ kind: 'failure', failure: describeRejection(thrown) }); } this.schedulePolling(); @@ -106,14 +127,42 @@ export class MixnetCoordinator { await this.ensureForConnectedSession(); } - /** Stops polling; the coordinator publishes nothing further. */ + /** Stops polling and recovery; the coordinator publishes nothing further. */ stop(): void { + this.clearPollTimer(); + if (this.recoveryTimerID !== undefined) { + clearTimeout(this.recoveryTimerID); + this.recoveryTimerID = undefined; + } + // A stopped coordinator can vouch for nothing: fail closed. + recordMixnetTransportReady(false); + } + + /** + * Clears only the poll interval. `schedulePolling` reschedules through + * this rather than [`stop`]: a reschedule must not close the fail-closed + * gate or cancel a pending recovery attempt. + */ + private clearPollTimer(): void { if (this.pollTimerID !== undefined) { clearInterval(this.pollTimerID); this.pollTimerID = undefined; } } + /** + * Whether the last observed transport state permits a fail-closed send: + * only a live `ready` report qualifies. The always-on send gate consults + * this; the stock flavors gate through the view's `sendBlocked` instead. + */ + isReady(): boolean { + return ( + this.lastStatus !== null && + this.lastStatus.kind === 'status' && + this.lastStatus.mode === RPCMixnetModeEnum.ready + ); + } + private async pollOnce(): Promise { if (this.pollLock) { return; @@ -127,7 +176,7 @@ export class MixnetCoordinator { } private schedulePolling(): void { - this.stop(); + this.clearPollTimer(); const cadence = this.isBootstrapping() ? BOOTSTRAP_POLL_MILLIS : STEADY_POLL_MILLIS; @@ -149,11 +198,39 @@ export class MixnetCoordinator { private publish(status: MixnetStatusReport): void { const wasBootstrapping = this.isBootstrapping(); this.lastStatus = status; + // Mirror readiness into the module gate for the detached price path. + recordMixnetTransportReady(this.isReady()); this.publishSeq += 1; this.publishView(status, this.publishSeq); if (this.pollTimerID !== undefined && wasBootstrapping !== this.isBootstrapping()) { this.schedulePolling(); } + this.maybeScheduleRecovery(); + } + + /** + * The always-on recovery loop: a failure report, a `died` transport, or + * an unconsented `off` (an enable that never succeeded — always-on has + * no consent path to `off`) schedules one fresh enable attempt. A failed + * attempt publishes a failure and thereby schedules the next, so the + * loop persists at [`RECOVERY_RETRY_MILLIS`] until the transport is up. + */ + private maybeScheduleRecovery(): void { + if (!this.autoRecover || this.recoveryTimerID !== undefined) { + return; + } + const needsRecovery = + this.lastStatus !== null && + (this.lastStatus.kind === 'failure' || + this.lastStatus.mode === RPCMixnetModeEnum.off || + this.lastStatus.mode === RPCMixnetModeEnum.died); + if (!needsRecovery) { + return; + } + this.recoveryTimerID = setTimeout(() => { + this.recoveryTimerID = undefined; + this.ensureForConnectedSession(); + }, RECOVERY_RETRY_MILLIS); } private async publishView( diff --git a/app/walletBackend/transforms/mixnetPresenter.ts b/app/walletBackend/transforms/mixnetPresenter.ts index 35cb839b0..86faa32aa 100644 --- a/app/walletBackend/transforms/mixnetPresenter.ts +++ b/app/walletBackend/transforms/mixnetPresenter.ts @@ -9,7 +9,7 @@ import { * the bootstrap, or re-enable a lost transport. A closed union so screens * must render every case the policy can produce. */ -export type MixnetRecoveryAction = 'none' | 'wait' | 'reenable'; +type MixnetRecoveryAction = 'none' | 'wait' | 'reenable'; /** * The screen-facing projection of the mixnet state. `statusKey` is a diff --git a/app/walletBackend/types/RPCBatchReportType.ts b/app/walletBackend/types/RPCBatchReportType.ts index 1b78d8c20..6416b2982 100644 --- a/app/walletBackend/types/RPCBatchReportType.ts +++ b/app/walletBackend/types/RPCBatchReportType.ts @@ -13,13 +13,13 @@ // completion); parts without an outcome entry were not attempted and remain // due. `error` marks an infrastructure failure (offline, no migration) where // no batch ran at all. -export type RPCPartSendResultType = +type RPCPartSendResultType = | { kind: 'sent'; txid: string } | { kind: 'slid' } | { kind: 'not_due'; window_opens_unix_time: number } | { kind: 'failed'; error: string }; -export type RPCPartOutcomeType = { +type RPCPartOutcomeType = { part: number; denomination: number; result: RPCPartSendResultType; diff --git a/app/walletBackend/types/RPCBatchStatusType.ts b/app/walletBackend/types/RPCBatchStatusType.ts index 552725ec1..ac3ca2993 100644 --- a/app/walletBackend/types/RPCBatchStatusType.ts +++ b/app/walletBackend/types/RPCBatchStatusType.ts @@ -3,7 +3,7 @@ // from the native call means no batch is running (before it starts, or once it // has finished); otherwise the counts are 0..=total and advance as the batch // proves, submits, then waits out the spacing before the next part. -export type RPCBatchStatusPhase = 'sending' | 'spacing'; +type RPCBatchStatusPhase = 'sending' | 'spacing'; export type RPCBatchStatusType = { // Parts owed this batch (N), fixed for the whole batch. diff --git a/app/walletBackend/types/RPCDrainStatusType.ts b/app/walletBackend/types/RPCDrainStatusType.ts index 3d0d0a08d..87b60af0b 100644 --- a/app/walletBackend/types/RPCDrainStatusType.ts +++ b/app/walletBackend/types/RPCDrainStatusType.ts @@ -3,7 +3,7 @@ // native call means no drain is running (before it starts, or once it has // finished); otherwise the counts are 0..=total and advance as the drain // builds then broadcasts. -export type RPCDrainStatusPhase = 'building' | 'transmitting'; +type RPCDrainStatusPhase = 'building' | 'transmitting'; export type RPCDrainStatusType = { // Total transactions in the plan (N), fixed for the whole drain. diff --git a/app/walletBackend/types/RPCMigrationStatusType.ts b/app/walletBackend/types/RPCMigrationStatusType.ts index 05d969492..0820b35a4 100644 --- a/app/walletBackend/types/RPCMigrationStatusType.ts +++ b/app/walletBackend/types/RPCMigrationStatusType.ts @@ -2,7 +2,7 @@ // `migration_status` (native `migrationStatusProcess`), arranged for direct // rendering. Values in zatoshis, heights in blocks, times in unix seconds. -export type RPCMigrationPhaseType = { +type RPCMigrationPhaseType = { kind: 'planned' | 'note_splitting' | 'parts_scheduled' | 'complete'; // note_splitting: the round currently awaiting confirmation (from zero). round?: number; @@ -35,7 +35,7 @@ export type RPCWakePointType = { // only future windows and structurally cannot hold this one, so the "send // batch" action reads it from here. `denominations` align element-for-element // with `part_ids`, both in the window's broadcast order. -export type RPCDueBatchType = { +type RPCDueBatchType = { // The current bucket's opening boundary (the parts' anchor height). boundary: number; part_ids: number[]; diff --git a/app/walletBackend/types/RPCSplitStepType.ts b/app/walletBackend/types/RPCSplitStepType.ts index 7832c432c..ebfd2193c 100644 --- a/app/walletBackend/types/RPCSplitStepType.ts +++ b/app/walletBackend/types/RPCSplitStepType.ts @@ -1,7 +1,7 @@ // What one `continue_note_splitting` call did (native // `continueNoteSplittingProcess`). The splitting loop matches on `step`: // sync between calls and keep going until `splitting_complete`. -export type RPCSplitStepKind = +type RPCSplitStepKind = 'round_broadcast' | 'awaiting_confirmation' | 'splitting_complete'; export type RPCSplitStepType = { diff --git a/app/walletBackend/utils/mixnetGate.ts b/app/walletBackend/utils/mixnetGate.ts new file mode 100644 index 000000000..5cff8e6bc --- /dev/null +++ b/app/walletBackend/utils/mixnetGate.ts @@ -0,0 +1,38 @@ +import { isMixnetAlwaysOn } from './nymTransport'; + +/** + * The always-on flavors' fail-closed gate state (CONTEXT.md: Fail-closed). + * + * Both covered surfaces — transaction broadcast and the CEX price fetch — + * must refuse rather than touch clearnet while the mixnet transport is not + * `ready`. The send path consults its coordinator instance directly; the + * price path runs through a detached module store with no coordinator + * access, so the coordinator mirrors its readiness here on every + * publication (and clears it on stop). + * + * In the stock flavors this module is inert: `coveredSurfacePermitted` + * is unconditionally true there, because the rendered mixnet UI already + * gates sends, and `off` is a consented clearnet state. + */ +let transportReady = false; + +/** Recorded by the MixnetCoordinator; not for screens or stores to call. */ +export function recordMixnetTransportReady(ready: boolean): void { + transportReady = ready; +} + +/** + * Whether a covered surface (send, price fetch) may proceed right now. + * False only in an always-on build whose transport is not `ready`. + */ +export function coveredSurfacePermitted(): boolean { + return !isMixnetAlwaysOn() || transportReady; +} + +/** + * The refusal text for a covered surface blocked by the gate. Carries the + * "Nym mixnet" marker, so classifySendFailure files it as mixnetRefusal — + * never a server problem, never retried elsewhere. + */ +export const COVERED_SURFACE_REFUSAL = + 'Error: refused: the Nym mixnet transport is not ready, and this always-on build never falls back to clearnet'; diff --git a/app/walletBackend/utils/nymTransport.ts b/app/walletBackend/utils/nymTransport.ts index 41383f629..0dc009dcd 100644 --- a/app/walletBackend/utils/nymTransport.ts +++ b/app/walletBackend/utils/nymTransport.ts @@ -12,17 +12,35 @@ import { StartMixnetTransport } from '../modules/MixnetCoordinator'; interface NymTransportModuleAPI { startMixnetTransport(): Promise; stopMixnetTransport(): Promise; + /** Constant from getConstants(): true only in the "always on" flavor. */ + mixnetAlwaysOn?: boolean; } -const NymTransportModule = NativeModules.NymTransportModule as NymTransportModuleAPI; +/** + * Resolved lazily on every call, never at module load: the module is absent + * on platforms without the native transport (iOS until the Mac-gated step), + * and eager capture couples every importer to the host's NativeModules + * shape (which broke the unit suites through the mixnet gate's import). + */ +function nymTransportModule(): NymTransportModuleAPI | undefined { + return NativeModules?.NymTransportModule as + | NymTransportModuleAPI + | undefined; +} /** * The injected `StartMixnetTransport` seam for the coordinator: (re)start * the platform-hosted proxy and yield its local SOCKS5 address. Rejections - * propagate to the coordinator, which publishes the typed failure view. + * propagate to the coordinator, which publishes the typed failure view; a + * platform without the module rejects the same way. */ -export const startMixnetTransport: StartMixnetTransport = () => - NymTransportModule.startMixnetTransport(); +export const startMixnetTransport: StartMixnetTransport = async () => { + const module = nymTransportModule(); + if (module === undefined) { + throw new Error('NymTransportModule is not present on this platform'); + } + return module.startMixnetTransport(); +}; /** * Deliberate teardown of the platform-hosted proxy (app shutdown or the @@ -30,5 +48,17 @@ export const startMixnetTransport: StartMixnetTransport = () => * cancels its liveness monitor before the listener goes down. */ export async function stopMixnetTransport(): Promise { - await NymTransportModule.stopMixnetTransport(); + await nymTransportModule()?.stopMixnetTransport(); +} + +/** + * Whether this build is the "Always On" flavor — the silent alpha APK + * (CONTEXT.md). True means the app withholds the Mixnet Mode UI projection + * (no toggle, banner, or disclaimer) while the forced-on, fail-closed + * transport policy runs unchanged, so a refusal surfaces as a plain send + * error. Guarded because the module is absent on platforms without the + * native transport (iOS until the Mac-gated step). + */ +export function isMixnetAlwaysOn(): boolean { + return nymTransportModule()?.mixnetAlwaysOn === true; } diff --git a/app/walletBackend/utils/walletFetchOutcome.ts b/app/walletBackend/utils/walletFetchOutcome.ts new file mode 100644 index 000000000..887ebcf79 --- /dev/null +++ b/app/walletBackend/utils/walletFetchOutcome.ts @@ -0,0 +1,61 @@ +/** + * The typed outcome of fetching the wallet's secret material (ADR 0002: + * errors are types; the getZecPrice / sendFailureTransform precedent). + * + * The transport arms come from [`decodeFfiJson`]; the arms owned here are + * the domain answers: + * - `complete`: the payload carried the key material the mode asked for. + * - `missingKeyMaterial`: well-formed JSON with no seed (or no ufvk in + * read-only mode) — the wallet answered, but there is nothing to back + * up, and no caller may treat that as a wallet. + */ +import { WalletType } from '../../AppState'; +import { decodeFfiJson, FfiErrorCode, FfiResult } from '../ffi'; + +export type WalletFetchOutcome = + | { readonly kind: 'complete'; readonly wallet: WalletType } + | { + readonly kind: 'ffiRejection'; + readonly code: FfiErrorCode; + readonly message: string; + } + | { readonly kind: 'emptyPayload' } + | { + readonly kind: 'malformedPayload'; + readonly payload: string; + readonly detail: string; + } + | { readonly kind: 'missingKeyMaterial'; readonly payload: string }; + +/** + * Classifies a settled native result. Pure: the FfiResult is data, so the + * whole boundary decision is testable without touching the bridge. + * + * `readOnly` selects which key material the mode requires — a ufvk for + * viewing-key wallets, a seed phrase otherwise. + */ +export function interpretWalletFetchResult( + result: FfiResult, + readOnly: boolean, +): WalletFetchOutcome { + const decoded = decodeFfiJson(result); + if (decoded.kind !== 'json') { + return decoded; + } + const parsed = decoded.value as { + seed_phrase?: unknown; + ufvk?: unknown; + birthday?: unknown; + } | null; + const key = readOnly ? parsed?.ufvk : parsed?.seed_phrase; + if (typeof key !== 'string' || key.length === 0) { + return { kind: 'missingKeyMaterial', payload: decoded.raw }; + } + // A birthday of 0 (genesis, e.g. regtest wallets) is a real height; only a + // missing or non-numeric field falls back to it. + const birthday = typeof parsed?.birthday === 'number' ? parsed.birthday : 0; + const wallet: WalletType = readOnly + ? { ufvk: key, birthday } + : { seed: key, birthday }; + return { kind: 'complete', wallet }; +} diff --git a/app/walletBackend/utils/walletUtils.ts b/app/walletBackend/utils/walletUtils.ts index 7f7166c4c..a02250026 100644 --- a/app/walletBackend/utils/walletUtils.ts +++ b/app/walletBackend/utils/walletUtils.ts @@ -12,49 +12,149 @@ */ import { WalletType, GlobalConst } from '../../AppState'; import RPCModule from '../../RPCModule'; -import { callFfi, FfiResult } from '../ffi'; +import { callFfi, decodeFfiJson, FfiErrorCode, FfiResult } from '../ffi'; +import { + COVERED_SURFACE_REFUSAL, + coveredSurfacePermitted, +} from './mixnetGate'; import { RPCZecPriceType } from '../types/RPCZecPriceType'; -import { RPCSeedType } from '../types/RPCSeedType'; +import { + WalletFetchOutcome, + interpretWalletFetchResult, +} from './walletFetchOutcome'; /** - * Fetches the current ZEC/USD price from the zingolib price oracle. - * - * Price sentinel values: - * 0 — initial/default (no price data yet) - * -1 — error inside zingolib (a typed FFI rejection, or an oracle error) - * -2 — malformed/empty success payload - * > 0 — real USD price + * The typed outcome of a price fetch, enumerated over the real ways the + * surface fails (ADR 0002: errors are types; the sendFailureTransform + * precedent). The legacy sentinel numbers (0 / -1 / -2) collapsed four + * genuinely different failures into one bucket; each arm here is grounded + * in a distinct producer: + * - `price`: the oracle answered with a usable USD price. + * - `noData`: the oracle answered, but carries no price yet. + * - `gateRefusal`: the always-on fail-closed gate refused before any + * network touch — the transport is not `ready`. + * - `ffiRejection`: the typed error channel rejected; `code` names the + * ZingolibError variant ('Read' is zingolib's oracle leg — over the + * mixnet in the always-on flavors; 'Mixnet' is the wallet's own + * fail-closed verdict; 'Unknown' is a bridge anomaly). + * - `oracleError`: the data channel resolved, and the payload itself + * reports the oracle failed. + * - `malformedPayload`: the resolution is unusable (empty, unparseable, + * or a non-numeric price) — the payload travels for diagnosis. + */ +export type ZecPriceOutcome = + | { readonly kind: 'price'; readonly usd: number } + | { readonly kind: 'noData' } + | { readonly kind: 'gateRefusal'; readonly error: string } + | { + readonly kind: 'ffiRejection'; + readonly code: FfiErrorCode; + readonly message: string; + } + | { readonly kind: 'oracleError'; readonly error: string } + | { + readonly kind: 'malformedPayload'; + readonly payload: string; + readonly detail: string; + }; + +/** + * One handler per [`ZecPriceOutcome`] arm, each receiving its narrowed + * variant. Exhaustive by construction: a new arm fails compilation at + * every handler record, by name, until the consumer decides what that + * arm means for it — no default case to forget. + */ +export type ZecPriceOutcomeHandlers = { + [K in ZecPriceOutcome['kind']]: ( + outcome: Extract, + ) => R; +}; + +/** + * Dispatches an outcome to its arm's handler. The assertion is safe by + * construction — the discriminant selects exactly the handler declared + * for it — and exists only because TypeScript cannot yet correlate a + * union-keyed record access with its argument. + */ +export function matchZecPriceOutcome( + outcome: ZecPriceOutcome, + handlers: ZecPriceOutcomeHandlers, +): R { + return (handlers[outcome.kind] as (o: ZecPriceOutcome) => R)(outcome); +} + +/** + * Fetches the current ZEC/USD price from the zingolib price oracle and + * classifies the outcome. Every failure arm also lands verbatim in the dev + * log: the screens render only a terse headline, and the silent alpha + * flavors have no other price diagnostics at all. */ -export async function getZecPrice(): Promise<{ - price: number; - error: string; -}> { - const result = await callFfi(RPCModule.zecPriceInfo()); - if (!result.ok) { - return { price: -1, error: result.error.message }; +export async function getZecPrice(): Promise { + // The always-on flavors' fail-closed gate: the price fetch reaches a CEX + // over HTTPS, so while the mixnet transport is not ready it must refuse + // rather than hand the exchange an IP-to-wallet correlation. + if (!coveredSurfacePermitted()) { + return { kind: 'gateRefusal', error: COVERED_SURFACE_REFUSAL }; } - if (!result.value) { - return { price: -2, error: 'Internal Error fetching price' }; + const startedAt = Date.now(); + const decoded = decodeFfiJson(await callFfi(RPCModule.zecPriceInfo())); + if (decoded.kind === 'ffiRejection') { + console.log( + 'price fetch failed: ffi rejection', + decoded.code, + decoded.message, + ); + return decoded; } - try { - const resultJSON: RPCZecPriceType = JSON.parse(result.value); - if (resultJSON.error) { - return { price: -1, error: resultJSON.error }; - } - if (!resultJSON.current_price) { - // if no exists the field or is empty - return { price: 0, error: '' }; - } - if (isNaN(resultJSON.current_price)) { - return { - price: -1, - error: `Error fetching price ${resultJSON.current_price}`, - }; - } - return { price: resultJSON.current_price, error: '' }; - } catch (error) { - return { price: -2, error: `Critical Error fetching price ${error}` }; + if (decoded.kind === 'emptyPayload') { + console.log('price fetch failed: empty success payload from the bridge'); + return { kind: 'malformedPayload', payload: '', detail: 'empty payload' }; + } + if (decoded.kind === 'malformedPayload') { + // The payload that failed to parse is the diagnostic; log it verbatim. + console.log( + 'price fetch failed: unparseable payload', + decoded.payload, + decoded.detail, + ); + return decoded; + } + if (typeof decoded.value !== 'object' || decoded.value === null) { + console.log('price fetch failed: non-object payload', decoded.raw); + return { + kind: 'malformedPayload', + payload: decoded.raw, + detail: 'non-object payload', + }; + } + const resultJSON = decoded.value as RPCZecPriceType; + if (resultJSON.error) { + console.log('price fetch failed: oracle error in payload', resultJSON.error); + return { kind: 'oracleError', error: resultJSON.error }; + } + if (!resultJSON.current_price) { + // if no exists the field or is empty + return { kind: 'noData' }; + } + if (isNaN(resultJSON.current_price)) { + console.log( + 'price fetch failed: non-numeric price', + String(resultJSON.current_price), + ); + return { + kind: 'malformedPayload', + payload: decoded.raw, + detail: `non-numeric price ${resultJSON.current_price}`, + }; } + // Success logs too: absence of a failure line must never be the only + // signal. + console.log( + 'price fetch ok:', + resultJSON.current_price, + `${Date.now() - startedAt}ms`, + ); + return { kind: 'price', usd: resultJSON.current_price }; } // --------------------------------------------------------------------------- @@ -463,16 +563,12 @@ export async function checkMyAddress( // error, parse error) we conservatively return false — better to show an // external address as external than mislabel a stranger's as own. export async function isWalletAddress(address: string): Promise { - const check = await checkMyAddress(address); - if (!check.ok || !check.value) { - return false; - } - try { - const parsed: { is_wallet_address?: boolean } = JSON.parse(check.value); - return parsed.is_wallet_address === true; - } catch (error) { - return false; - } + const decoded = decodeFfiJson(await checkMyAddress(address)); + return ( + decoded.kind === 'json' && + (decoded.value as { is_wallet_address?: unknown } | null) + ?.is_wallet_address === true + ); } // True iff a wallet backup file exists on disk. zingolib reports the answer @@ -506,56 +602,41 @@ export async function shieldConfirm(): Promise> { } /** - * Returns the wallet's secret material for the backup/seed display screens. + * Fetches the wallet's secret material and names the outcome. + * + * readOnly = true → the wallet is { birthday, ufvk } (viewing-key wallets) + * readOnly = false → the wallet is { birthday, seed } (full wallet) * - * readOnly = true → returns { birthday, ufvk } (viewing-key wallets only) - * readOnly = false → returns { birthday, seed } (full wallet) - * Returns null on any error. + * Callers that store or display the material must switch on the outcome: + * every non-`complete` arm is a distinct failure, and none of them is a + * wallet. See [`WalletFetchOutcome`]. + */ +export async function fetchWalletOutcome( + readOnly: boolean, +): Promise { + const result = await callFfi( + readOnly ? RPCModule.getUfvkInfo() : RPCModule.getSeedInfo(), + ); + const outcome = interpretWalletFetchResult(result, readOnly); + if (outcome.kind !== 'complete') { + console.log( + `${readOnly ? 'ufvk' : 'seed'} fetch failed:`, + outcome.kind, + outcome.kind === 'ffiRejection' ? outcome.message : '', + ); + } + return outcome; +} + +/** + * Presence-only view of [`fetchWalletOutcome`]: the wallet when the fetch + * produced one, null otherwise. Callers that must react to failure — or + * must not mistake "no key material" for a usable wallet — take the + * outcome instead. */ export async function fetchWallet( readOnly: boolean, ): Promise { - if (readOnly) { - // only viewing key & birthday - const result = await callFfi(RPCModule.getUfvkInfo()); - if (!result.ok || !result.value) { - return null; - } - try { - const RPCufvk: WalletType = JSON.parse(result.value); - - const wallet: WalletType = {} as WalletType; - if (RPCufvk.birthday) { - wallet.birthday = RPCufvk.birthday; - } - if (RPCufvk.ufvk) { - wallet.ufvk = RPCufvk.ufvk; - } - - return wallet; - } catch (error) { - return null; - } - } else { - // only seed & birthday - const result = await callFfi(RPCModule.getSeedInfo()); - if (!result.ok || !result.value) { - return null; - } - try { - const RPCseed: RPCSeedType = JSON.parse(result.value); - - const wallet: WalletType = {} as WalletType; - if (RPCseed.seed_phrase) { - wallet.seed = RPCseed.seed_phrase; - } - if (RPCseed.birthday) { - wallet.birthday = RPCseed.birthday; - } - - return wallet; - } catch (error) { - return null; - } - } + const outcome = await fetchWalletOutcome(readOnly); + return outcome.kind === 'complete' ? outcome.wallet : null; } diff --git a/components/AddressBook/AddressBook.tsx b/components/AddressBook/AddressBook.tsx index 2444123e9..4135cadc5 100644 --- a/components/AddressBook/AddressBook.tsx +++ b/components/AddressBook/AddressBook.tsx @@ -30,6 +30,7 @@ import { ScreenEnum, } from '../../app/AppState'; import { AppDrawerParamList, ThemeType } from '../../app/types'; +import { deriveListSelection } from '../../app/utils/listSelection'; import FadeText from '../Components/FadeText'; import BoldText from '../Components/BoldText'; import Button from '../Components/Button'; @@ -533,6 +534,8 @@ const AddressBook: React.FunctionComponent = ({ [colors, abDetailTitle], ); + const detailSelection = deriveListSelection(addressBookSliced, currentItem); + return ( = ({ key={`detail-${abDetailKey}`} index={currentItem ?? -1} item={ - currentItem !== null && - currentItem > -1 && - addressBookSliced[currentItem] - ? addressBookSliced[currentItem] + // Both null (sheet closed) and -1 (Add-new mode) mean "no + // real item"; deriveListSelection folds them into one + // non-selected state. + detailSelection.kind === 'selected' + ? detailSelection.item : ({} as AddressBookFileClass) } cancel={cancel} diff --git a/components/Components/SelectBottomSheet.tsx b/components/Components/SelectBottomSheet.tsx index f43595407..47c6e966f 100644 --- a/components/Components/SelectBottomSheet.tsx +++ b/components/Components/SelectBottomSheet.tsx @@ -20,7 +20,7 @@ import RegText from './RegText'; import { ThemeType } from '../../app/types'; import { useKeyboardHeight } from '../../app/hooks/useKeyboardHeight'; -export type SelectBottomSheetItem = { +type SelectBottomSheetItem = { label: string; value: string; }; diff --git a/components/Components/priceFetcherStore.ts b/components/Components/priceFetcherStore.ts index 4a3653e30..5126a2d34 100644 --- a/components/Components/priceFetcherStore.ts +++ b/components/Components/priceFetcherStore.ts @@ -1,5 +1,9 @@ import { useEffect, useReducer } from 'react'; -import { getZecPrice } from '../../app/walletBackend'; +import { + getZecPrice, + matchZecPriceOutcome, + ZecPriceOutcome, +} from '../../app/walletBackend'; /** * Shared, singleton state for every on screen. @@ -74,33 +78,36 @@ async function doFetch(): Promise { if (cooldownTimer) clearTimeout(cooldownTimer); cooldownTimer = setTimeout(emit, COOLDOWN_MS); - let price: number; - let error: string; - // first attempt - ({ price, error } = await getZecPrice()); - // 0 initial · -1 Gemini/zingolib · -2 RPCModule · >0 real value - if (price <= 0) { - // second attempt - ({ price, error } = await getZecPrice()); + // first attempt, and one retry on anything but a real price + let outcome: ZecPriceOutcome = await getZecPrice(); + if (outcome.kind !== 'price') { + outcome = await getZecPrice(); } - if (price === -1) { + // Exhaustive by construction: the handler record must name every + // ZecPriceOutcome arm, so adding an arm fails compilation here, by + // name, until this store decides how to render it. + const geminiSnackbar = (detail: string) => d.addLastSnackbar( - `${d.translate('info.errorgemini') as string} - ${error}`, + `${d.translate('info.errorgemini') as string} - ${detail}`, ); - } else if (price === -2) { - d.addLastSnackbar( - `${d.translate('info.errorrpcmodule') as string} - ${error}`, - ); - } else if (price <= 0) { - d.addLastSnackbar( - `${d.translate('info.errorgemini') as string} - ${error}`, - ); - d.setZecPrice(price, 0); - } else { - d.setZecPrice(price, Date.now()); - started = true; - } + matchZecPriceOutcome(outcome, { + price: o => { + d.setZecPrice(o.usd, Date.now()); + started = true; + }, + noData: () => { + geminiSnackbar(''); + d.setZecPrice(0, 0); + }, + gateRefusal: o => geminiSnackbar(o.error), + ffiRejection: o => geminiSnackbar(o.message), + oracleError: o => geminiSnackbar(o.error), + malformedPayload: o => + d.addLastSnackbar( + `${d.translate('info.errorrpcmodule') as string} - ${o.detail}`, + ), + }); // Floor the visible loading time so the CTA/ring state doesn't flash by. const elapsed = Date.now() - now; diff --git a/components/OptionsPanel/index.ts b/components/OptionsPanel/index.ts index 45521cc98..975f9453d 100644 --- a/components/OptionsPanel/index.ts +++ b/components/OptionsPanel/index.ts @@ -1,7 +1,2 @@ -export { default } from './OptionsPanel'; export { default as OptionsPanelHost } from './OptionsPanelHost'; -export type { - OptionsPanelAction, - OptionsPanelProps, - OptionsPanelSocial, -} from './OptionsPanel'; +export type { OptionsPanelAction, OptionsPanelSocial } from './OptionsPanel'; diff --git a/components/Receive/Receive.tsx b/components/Receive/Receive.tsx index a30c910d2..83a0971c1 100644 --- a/components/Receive/Receive.tsx +++ b/components/Receive/Receive.tsx @@ -17,6 +17,7 @@ import { faXmark, } from '@fortawesome/free-solid-svg-icons'; import SelectBottomSheet from '../Components/SelectBottomSheet'; +import { deriveListSelection } from '../../app/utils/listSelection'; import Clipboard from '@react-native-clipboard/clipboard'; import SingleAddress from '../Components/SingleAddress'; @@ -278,6 +279,20 @@ const Receive: React.FunctionComponent = ({ } }, [addresses]); + // The effect above encodes an empty filtered list as index 0, so a + // non-null index is not by itself proof that an address exists. + // deriveListSelection maps every (list, index) pair to exactly one + // named state. + const currentSelection = useMemo( + () => + index === 0 + ? deriveListSelection(uAddr, uAddrIndex) + : deriveListSelection(tAddr, tAddrIndex), + [index, uAddr, uAddrIndex, tAddr, tAddrIndex], + ); + const selectedAddressText = + currentSelection.kind === 'selected' ? currentSelection.item.address : ''; + const isAdvanced = mode !== ModeEnum.basic; const canPickScope = isAdvanced && tAddr && tAddr.length > 0; @@ -373,13 +388,7 @@ const Receive: React.FunctionComponent = ({ ); const doCopy = () => { - Clipboard.setString( - index === 0 && uAddrIndex !== null - ? uAddr[uAddrIndex].address - : index === 1 && tAddrIndex !== null - ? tAddr[tAddrIndex].address - : '', - ); + Clipboard.setString(selectedAddressText); addLastSnackbar( translate('history.addresscopied') as string, SnackbarDurationEnum.short, @@ -388,33 +397,30 @@ const Receive: React.FunctionComponent = ({ // Resolve the address currently shown based on the scope index. const currentAddress = useMemo(() => { - if (index === 0) { - if (uAddrIndex !== null && uAddr.length > 0) { - return uAddr[uAddrIndex]; - } - return new UnifiedAddressClass( - 0, - translate('receive.noaddress') as string, - AddressKindEnum.u, - false, - false, - false, - ); - } - if (tAddrIndex !== null && tAddr.length > 0) { - return tAddr[tAddrIndex]; + if (currentSelection.kind === 'selected') { + return currentSelection.item; } - return new TransparentAddressClass( - 0, - translate('receive.noaddress') as string, - AddressKindEnum.t, - RPCAddressScopeEnum.external, - ); - }, [index, uAddrIndex, tAddrIndex, uAddr, tAddr, translate]); + return index === 0 + ? new UnifiedAddressClass( + 0, + translate('receive.noaddress') as string, + AddressKindEnum.u, + false, + false, + false, + ) + : new TransparentAddressClass( + 0, + translate('receive.noaddress') as string, + AddressKindEnum.t, + RPCAddressScopeEnum.external, + ); + }, [currentSelection, index, translate]); const setCurrentAddrIndex = index === 0 ? setUAddrIndex : setTAddrIndex; const currentTotal = index === 0 ? uAddr.length : tAddr.length; - const currentAddrIndex = index === 0 ? (uAddrIndex ?? 0) : (tAddrIndex ?? 0); + const currentAddrIndex = + currentSelection.kind === 'selected' ? currentSelection.index : 0; const returnPage = ( = ({ )} {sheetType === 'NAT' && ( = ({ closeSheet={hide} title={translate('receive.title-address') as string} button={translate('receive.copy-address-button') as string} - address={ - index === 0 && uAddrIndex !== null - ? uAddr[uAddrIndex].address - : index === 1 && tAddrIndex !== null - ? tAddr[tAddrIndex].address - : '' - } + address={selectedAddressText} /> )} diff --git a/components/Receive/components/VerifyAddress.tsx b/components/Receive/components/VerifyAddress.tsx index ad620a899..3c5ebc146 100644 --- a/components/Receive/components/VerifyAddress.tsx +++ b/components/Receive/components/VerifyAddress.tsx @@ -20,7 +20,10 @@ import { checkMyAddress } from '../../../app/walletBackend'; import { parseZcashURI } from '../../../app/uris'; import TextInputAddress from '../../Components/TextInputAddress'; import FadeText from '../../Components/FadeText'; -import { RPCCheckAddressType } from '../../../app/walletBackend/types/RPCCheckAddressType'; +import { + CheckAddressVerdict, + interpretCheckAddressResult, +} from './checkAddressVerdict'; import { VerifyCheckIcon } from '../../Components/Icons/VerifyCheckIcon'; import { VerifyXIcon } from '../../Components/Icons/VerifyXIcon'; @@ -42,26 +45,28 @@ const VerifyAddress: React.FunctionComponent = ({ const [address, setAddress] = useState(''); const [errorAddress, setErrorAddress] = useState(''); - const [verifyOK, setVerifyOK] = useState(null); + const [verdict, setVerdict] = useState({ + kind: 'unattempted', + }); const verifyAddress = async () => { - try { - const verifyAddressResult = await checkMyAddress(address); - if (!verifyAddressResult.ok) { - addLastSnackbar( - verifyAddressResult.error.message, - SnackbarDurationEnum.short, - ); - setErrorAddress(verifyAddressResult.error.message); - } else { - const verifyAddressJSON: RPCCheckAddressType = await JSON.parse( - verifyAddressResult.value, - ); - setVerifyOK(verifyAddressJSON.is_wallet_address); - } - } catch (error) { - console.log(`Critical Error new address ${error}`); + const result = interpretCheckAddressResult(await checkMyAddress(address)); + + // A failed check must never reach the verdict rows: rendering "this + // address does not belong to you" when the backend never answered is a + // confident false negative on a verification screen. + if (result.kind === 'ffiRejection') { + console.log(`Error new address ${result.code} ${result.message}`); + addLastSnackbar(result.message, SnackbarDurationEnum.short); + setErrorAddress(result.message); + } else if (result.kind === 'malformed') { + console.log(`Internal Error new address ${result.reason}`); + addLastSnackbar( + translate('receive.verification-unavailable') as string, + SnackbarDurationEnum.short, + ); } + setVerdict(result); Keyboard.dismiss(); }; @@ -128,7 +133,7 @@ const VerifyAddress: React.FunctionComponent = ({ {errorAddress} )} - {verifyOK !== null && ( + {(verdict.kind === 'mine' || verdict.kind === 'notMine') && ( = ({ marginVertical: 5, }} > - {verifyOK ? ( + {verdict.kind === 'mine' ? ( , +): CheckAddressVerdict { + const decoded = decodeFfiJson(result); + switch (decoded.kind) { + case 'ffiRejection': + return decoded; + case 'emptyPayload': + return { kind: 'malformed', reason: 'empty payload' }; + case 'malformedPayload': + return { kind: 'malformed', reason: decoded.detail }; + case 'json': { + const parsed = decoded.value as { is_wallet_address?: unknown } | null; + if (typeof parsed?.is_wallet_address !== 'boolean') { + return { kind: 'malformed', reason: 'missing is_wallet_address' }; + } + return parsed.is_wallet_address ? { kind: 'mine' } : { kind: 'notMine' }; + } + } +} diff --git a/components/Send/Send.tsx b/components/Send/Send.tsx index a5dcd1a08..cf1b598dc 100644 --- a/components/Send/Send.tsx +++ b/components/Send/Send.tsx @@ -84,6 +84,11 @@ import { sendFailureMessage, } from '../../app/walletBackend/transforms/sendFailureTransform'; import Utils from '../../app/utils'; +import { + applySendFieldUpdates, + SendFields, + SendFieldUpdate, +} from './sendFieldUpdates'; import { safeSnapToIndex } from '../../app/utils/safeSnapToIndex'; import { AppDrawerParamList, ThemeType } from '../../app/types'; import { ContextAppLoaded } from '../../app/context'; @@ -507,13 +512,12 @@ const Send: React.FunctionComponent = ({ Utils.getZenniesDonationAmount(), ) : 0); - updateToField( - null, - Utils.parseNumberFloatToStringLocale(newAmount, 8), - null, - null, - null, - ); + updateToField([ + { + field: 'amount', + value: Utils.parseNumberFloatToStringLocale(newAmount, 8), + }, + ]); setProposeSendLastError(''); } } @@ -619,99 +623,78 @@ const Send: React.FunctionComponent = ({ ], ); - const updateToField = async ( - addressPar: string | null, - amountPar: string | null, - amountCurrencyPar: string | null, - memoPar: string | null, - includeUAMemoPar: boolean | null, - ) => { - if (addressPar !== null) { - //Alert.alert('', addressPar); - //setAddressText(addressPar); - // Attempt to parse as URI if it starts with zcash - if ( - addressPar.toLowerCase().startsWith(GlobalConst.zcash) || - addressPar.toLowerCase().includes(':') - ) { - const { error, target } = await parseZcashURI( - addressPar, - translate, - server, - ); + const updateToField = async (updates: readonly SendFieldUpdate[]) => { + let effective = updates; + const addressUpdate = updates.find( + (u): u is Extract => + u.field === 'address', + ); + // A URI-shaped address needs the async parser and can abort the whole + // batch on error, so it is handled here rather than in the pure core. + if ( + addressUpdate && + (addressUpdate.value.toLowerCase().startsWith(GlobalConst.zcash) || + addressUpdate.value.toLowerCase().includes(':')) + ) { + const { error, target } = await parseZcashURI( + addressUpdate.value, + translate, + server, + ); - // Audit Issue H — surface the parser error and abort before any - // Send-state mutation. parseZcashURI now returns an empty target - // when error is non-empty, but the explicit guard keeps intent - // obvious here and protects against future contract changes. - if (error) { - addLastSnackbar(error); - return; - } + // Audit Issue H — surface the parser error and abort before any + // Send-state mutation. parseZcashURI now returns an empty target + // when error is non-empty, but the explicit guard keeps intent + // obvious here and protects against future contract changes. + if (error) { + addLastSnackbar(error); + return; + } - if (target) { - // redo the to addresses - [target].forEach(tgt => { - if (tgt.address) { - setAddressText(tgt.address); - } - if (tgt.amount) { - setAmountText( - Utils.parseNumberFloatToStringLocale(tgt.amount, 8), - ); - } - if (tgt.memoString) { - setMemoText(tgt.memoString); - } - }); + if (target) { + // The URI's fields are written verbatim: a URI amount replaces + // the ZEC amount without recomputing the fiat field. + if (target.address) { + setAddressText(target.address); + } + if (target.amount) { + setAmountText(Utils.parseNumberFloatToStringLocale(target.amount, 8)); + } + if (target.memoString) { + setMemoText(target.memoString); } - } else { - setAddressText(addressPar.replace(/[ \t\n\r]+/g, '')); // Remove spaces } + effective = updates.filter(u => u !== addressUpdate); } - if (amountPar !== null) { - const amountTemp = amountPar.substring(0, 20); - if (isNaN(Utils.parseStringLocaleToNumberFloat(amountTemp))) { - setAmountCurrencyText(''); - } else if (amountTemp && zecPrice && zecPrice.zecPrice > 0) { - setAmountCurrencyText( - Utils.parseNumberFloatToStringLocale( - Utils.parseStringLocaleToNumberFloat(amountTemp) * - zecPrice.zecPrice, - 2, - ), - ); - } else { - setAmountCurrencyText(''); - } - setAmountText(amountTemp); + const prev: SendFields = { + address: addressText, + amount: amountText, + amountCurrency: amountCurrencyText, + memo: memoText, + includeUAMemo: includeUAMemoBoolean, + }; + const next = applySendFieldUpdates( + prev, + effective, + zecPrice ? zecPrice.zecPrice : 0, + ); + // Write only the fields this batch changed: an untouched field must + // not be re-written from this call's render snapshot of the state. + if (next.address !== prev.address) { + setAddressText(next.address); } - - if (amountCurrencyPar !== null) { - const amountCurrencyTemp = amountCurrencyPar.substring(0, 15); - if (isNaN(Utils.parseStringLocaleToNumberFloat(amountCurrencyTemp))) { - setAmountText(''); - } else if (amountCurrencyTemp && zecPrice && zecPrice.zecPrice > 0) { - setAmountText( - Utils.parseNumberFloatToStringLocale( - Utils.parseStringLocaleToNumberFloat(amountCurrencyTemp) / - zecPrice.zecPrice, - 8, - ), - ); - } else { - setAmountText(''); - } - setAmountCurrencyText(amountCurrencyTemp); + if (next.amount !== prev.amount) { + setAmountText(next.amount); } - - if (memoPar !== null) { - setMemoText(memoPar); + if (next.amountCurrency !== prev.amountCurrency) { + setAmountCurrencyText(next.amountCurrency); } - - if (includeUAMemoPar !== null) { - setIncludeUAMemoBoolean(includeUAMemoPar); + if (next.memo !== prev.memo) { + setMemoText(next.memo); + } + if (next.includeUAMemo !== prev.includeUAMemo) { + setIncludeUAMemoBoolean(next.includeUAMemo); } }; @@ -1072,7 +1055,8 @@ const Send: React.FunctionComponent = ({ const setQrcodeModalShow = () => { navigation.navigate(RouteEnum.ScannerAddress, { - setAddress: (a: string) => updateToField(a, null, null, null, null), + setAddress: (a: string) => + updateToField([{ field: 'address', value: a }]), active: true, }); }; @@ -1349,7 +1333,7 @@ const Send: React.FunctionComponent = ({ }} value={addressText} onChangeText={(text: string) => { - updateToField(text, null, null, null, null); + updateToField([{ field: 'address', value: text }]); }} editable={true} /> @@ -1365,7 +1349,7 @@ const Send: React.FunctionComponent = ({ {addressText && ( { - updateToField('', null, null, null, null); + updateToField([{ field: 'address', value: '' }]); }} > = ({ }} value={amountText} onChangeText={(text: string) => - updateToField( - null, - text.substring(0, 20), - null, - null, - null, - ) + updateToField([ + { field: 'amount', value: text.substring(0, 20) }, + ]) } editable={true} maxLength={20} @@ -1581,13 +1561,12 @@ const Send: React.FunctionComponent = ({ }} value={amountCurrencyText} onChangeText={(text: string) => - updateToField( - null, - null, - text.substring(0, 15), - null, - null, - ) + updateToField([ + { + field: 'amountCurrency', + value: text.substring(0, 15), + }, + ]) } editable={true} maxLength={15} @@ -1597,8 +1576,10 @@ const Send: React.FunctionComponent = ({ inputZec - ? updateToField(null, '', null, null, null) - : updateToField(null, null, '', null, null) + ? updateToField([{ field: 'amount', value: '' }]) + : updateToField([ + { field: 'amountCurrency', value: '' }, + ]) } > = ({ maxAmount, 8, ); - updateToField(null, maxStr, null, null, null); + updateToField([ + { field: 'amount', value: maxStr }, + ]); calculateFeeWithPropose( maxStr, addressText, @@ -1939,24 +1922,22 @@ const Send: React.FunctionComponent = ({ }} value={memoText} onChangeText={(text: string) => { - updateToField( - null, - !amountText && !!text ? '0' : null, - null, - text, - null, - ); + updateToField([ + ...(!amountText && !!text + ? [{ field: 'amount', value: '0' } as const] + : []), + { field: 'memo', value: text }, + ]); }} onEndEditing={( e: NativeSyntheticEvent, ) => { - updateToField( - null, - !amountText && !!e.nativeEvent.text ? '0' : null, - null, - e.nativeEvent.text, - null, - ); + updateToField([ + ...(!amountText && !!e.nativeEvent.text + ? [{ field: 'amount', value: '0' } as const] + : []), + { field: 'memo', value: e.nativeEvent.text }, + ]); calculateFeeWithPropose( amountText, addressText, @@ -2011,7 +1992,7 @@ const Send: React.FunctionComponent = ({ {memoText && ( { - updateToField(null, null, null, '', null); + updateToField([{ field: 'memo', value: '' }]); }} > = ({ disabled={!sendButtonEnabled} onPress={async () => { setSendButtonEnabled(false); - updateToField(null, null, null, memoText, null); + updateToField([{ field: 'memo', value: memoText }]); // donation - a Zenny is the minimum if ( server.chainName === ChainNameEnum.mainChainName && @@ -2199,13 +2180,13 @@ const Send: React.FunctionComponent = ({ addLastSnackbar( `${translate('send.donation-minimum-message') as string}`, ); - updateToField( - null, - Utils.getZenniesDonationAmount(), - null, - null, - false, - ); + updateToField([ + { + field: 'amount', + value: Utils.getZenniesDonationAmount(), + }, + { field: 'includeUAMemo', value: false }, + ]); return; } if ( @@ -2233,7 +2214,10 @@ const Send: React.FunctionComponent = ({ // if the address is transparent - clean the memo field Just in Case. if (!memoEnabled) { setMemoText(''); - updateToField(null, null, null, '', false); + updateToField([ + { field: 'memo', value: '' }, + { field: 'includeUAMemo', value: false }, + ]); } // Prefetch the parsed address for the Confirm screen // so its Privacy Level badge renders without waiting on @@ -2304,15 +2288,23 @@ const Send: React.FunctionComponent = ({ update = true; } if (update) { - updateToField( - await Utils.getDonationAddress( - server.chainName, - ), - Utils.getDonationAmount(), - null, - Utils.getDonationMemo(translate), - true, - ); + updateToField([ + { + field: 'address', + value: await Utils.getDonationAddress( + server.chainName, + ), + }, + { + field: 'amount', + value: Utils.getDonationAmount(), + }, + { + field: 'memo', + value: Utils.getDonationMemo(translate), + }, + { field: 'includeUAMemo', value: true }, + ]); } }} > @@ -2403,16 +2395,16 @@ const Send: React.FunctionComponent = ({ setUpdatingToField(true); await ShowAddressAlertAsync(translate) .then(() => { - updateToField(itemValue, null, null, null, null); + updateToField([{ field: 'address', value: itemValue }]); }) .catch(() => { - updateToField(addressText, null, null, null, null); + updateToField([{ field: 'address', value: addressText }]); }); setTimeout(() => { setUpdatingToField(false); }, 500); } else if (addressText !== itemValue) { - updateToField(itemValue, null, null, null, null); + updateToField([{ field: 'address', value: itemValue }]); } }} /> diff --git a/components/Send/sendFieldUpdates.ts b/components/Send/sendFieldUpdates.ts new file mode 100644 index 000000000..44ac37560 --- /dev/null +++ b/components/Send/sendFieldUpdates.ts @@ -0,0 +1,82 @@ +/** + * Pure core of the Send form's field updates. + * + * A form write is a value of [`SendFieldUpdate`]: the field is named by the + * discriminant, so a call site states *which* field it writes and the type + * system rejects a wrongly-shaped one — there are no positional slots to + * transpose. `applySendFieldUpdates` folds a batch of updates over the + * current field values and owns the one coupling in the form: the two + * amount fields are a single value in two units, so writing either + * recomputes its counterpart from the ZEC price, and clears it when the + * input is not a number or no price is known (which is why clearing + * either amount clears both). + * + * Pure and side-effect free: fields in, fields out. The URI-bearing + * address path (async parser, user-facing errors) lives with the caller; + * the `address` arm here receives plain addresses and strips whitespace. + */ +import { + parseNumberFloatToStringLocale, + parseStringLocaleToNumberFloat, +} from '../../app/utils/localeNumber'; + +export type SendFieldUpdate = + | { readonly field: 'address'; readonly value: string } + | { readonly field: 'amount'; readonly value: string } + | { readonly field: 'amountCurrency'; readonly value: string } + | { readonly field: 'memo'; readonly value: string } + | { readonly field: 'includeUAMemo'; readonly value: boolean }; + +export type SendFields = { + readonly address: string; + readonly amount: string; + readonly amountCurrency: string; + readonly memo: string; + readonly includeUAMemo: boolean; +}; + +const applyOne = ( + fields: SendFields, + update: SendFieldUpdate, + zecPriceUsd: number, +): SendFields => { + switch (update.field) { + case 'address': + return { ...fields, address: update.value.replace(/[ \t\n\r]+/g, '') }; + case 'amount': { + const amount = update.value.substring(0, 20); + const parsed = parseStringLocaleToNumberFloat(amount); + const amountCurrency = isNaN(parsed) + ? '' + : amount && zecPriceUsd > 0 + ? parseNumberFloatToStringLocale(parsed * zecPriceUsd, 2) + : ''; + return { ...fields, amount, amountCurrency }; + } + case 'amountCurrency': { + const amountCurrency = update.value.substring(0, 15); + const parsed = parseStringLocaleToNumberFloat(amountCurrency); + const amount = isNaN(parsed) + ? '' + : amountCurrency && zecPriceUsd > 0 + ? parseNumberFloatToStringLocale(parsed / zecPriceUsd, 8) + : ''; + return { ...fields, amount, amountCurrency }; + } + case 'memo': + return { ...fields, memo: update.value }; + case 'includeUAMemo': + return { ...fields, includeUAMemo: update.value }; + } +}; + +export function applySendFieldUpdates( + prev: SendFields, + updates: readonly SendFieldUpdate[], + zecPriceUsd: number, +): SendFields { + return updates.reduce( + (fields, update) => applyOne(fields, update, zecPriceUsd), + prev, + ); +} diff --git a/docs/adr/0004-typed-outcomes-are-consumed-through-exhaustive-handler-records.md b/docs/adr/0004-typed-outcomes-are-consumed-through-exhaustive-handler-records.md new file mode 100644 index 000000000..4185f22ea --- /dev/null +++ b/docs/adr/0004-typed-outcomes-are-consumed-through-exhaustive-handler-records.md @@ -0,0 +1,55 @@ +# 4. Typed outcomes are consumed through exhaustive handler records + +Date: 2026-07-24 + +## Status + +Accepted. Ratified during the silent-alpha verification session, first +implemented for the price-fetch surface (`ZecPriceOutcome`, +`matchZecPriceOutcome`). + +## Context + +ADR 0002 makes failures discriminated unions instead of prose; this ADR +governs how those unions are consumed. A union alone does not force a +consumer to consider every arm: an `if`/`else if` chain silently ignores +the arms it never names, and a `switch` is exhaustive only while someone +maintains a `never`-typed `default` — a discipline, not a construction. +Both shapes met their failure mode in this codebase: the send path's +legacy classifier collapsed genuinely different failures into one bucket +(ADR 0002's context), and the price surface flattened four distinct +failure producers into one sentinel number and one string, which cost +diagnostic information exactly when the silent alpha flavors needed it. + +## Decision + +A discriminated union whose arms demand different consumer behavior is +consumed through an exhaustive handler record: a mapped type requiring +exactly one handler per discriminant, each receiving its narrowed arm +(`ZecPriceOutcomeHandlers` is the template), dispatched by a single +generic `match` function published beside the union. Adding an arm to +the union then fails compilation at every consumer, naming the missing +handler, until each consumer decides what the new arm means for it. +Removing or misspelling an arm fails the same way. There is no +`default` to forget and no fallthrough to misorder. + +The `match` function contains the pattern's one assertion — TypeScript +cannot yet correlate a union-keyed record access with its argument — and +that assertion is confined there, documented, and safe by construction: +the discriminant selects exactly the handler declared for it. Consumers +never repeat it. + +`switch` with a `never` default remains acceptable inside pure +transforms that fold a union into a value in one place; the handler +record is required where the union crosses a module boundary to +consumers that render, route, or otherwise act on the arms. + +## Consequences + +New outcome arms propagate as compile errors to every consumer, which is +the point: the person adding the arm is forced to visit each rendering +decision rather than inherit a silent bucket. The cost is one generic +helper and one confined assertion per union, and slightly more ceremony +than a `switch` for single-consumer unions — which is why pure local +folds are exempted. The pattern needs no dependencies and no runtime +machinery beyond a record lookup. diff --git a/ios/RPCModule.swift b/ios/RPCModule.swift index f84e21d59..1a526c145 100644 --- a/ios/RPCModule.swift +++ b/ios/RPCModule.swift @@ -52,6 +52,7 @@ enum FfiOutcome { case .Sync(let message): return ("Sync", message) case .Rescan(let message): return ("Rescan", message) case .Read(let message): return ("Read", message) + case .Mixnet(let message): return ("Mixnet", message) case .Send(let message): return ("Send", message) case .Shield(let message): return ("Shield", message) case .InvalidInput(let message): return ("InvalidInput", message) diff --git a/ios/ZingoTests/ZingoTest.swift b/ios/ZingoTests/ZingoTest.swift index 0ccaf71b4..4725e4b51 100644 --- a/ios/ZingoTests/ZingoTest.swift +++ b/ios/ZingoTests/ZingoTest.swift @@ -834,6 +834,7 @@ class FfiOutcomeTests: XCTestCase { (ZingolibError.Sync(message: "boom"), "Sync"), (ZingolibError.Rescan(message: "boom"), "Rescan"), (ZingolibError.Read(message: "boom"), "Read"), + (ZingolibError.Mixnet(message: "boom"), "Mixnet"), (ZingolibError.Send(message: "boom"), "Send"), (ZingolibError.Shield(message: "boom"), "Shield"), (ZingolibError.InvalidInput(message: "boom"), "InvalidInput"), diff --git a/package.json b/package.json index c10899721..a99f0fa01 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "scripts": { "android:prod": "react-native run-android --mode prodDebug", "android:beta": "react-native run-android --mode betaDebug", + "android:alwayson": "react-native run-android --mode alwaysonDebug", + "android:alwaysontest": "react-native run-android --mode alwaysontestDebug", "ios": "react-native run-ios", "start": "react-native start", "test": "jest --verbose", diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs index 4a5276ae7..1396e81e1 100644 --- a/rust/lib/src/lib.rs +++ b/rust/lib/src/lib.rs @@ -126,12 +126,27 @@ impl ZingolibError { } } +/// Renders an error with its full `source` chain, deepest cause last. +/// Display alone truncates: the price fetch's UnknownIssuer root cause hid +/// for a whole debugging session under three layers of "request failed" — +/// the chain is the diagnostic, so the FFI text carries all of it. +fn error_chain_text(e: &dyn std::error::Error) -> String { + let mut text = e.to_string(); + let mut source = e.source(); + while let Some(cause) = source { + text.push_str(": "); + text.push_str(&cause.to_string()); + source = cause.source(); + } + text +} + /// The one pure funnel from zingolib's error taxonomy to the FFI's typed /// variants. Exhaustive at every level on purpose: a new zingolib variant /// fails compilation here instead of degrading to prose in the data channel. fn ffi_error(e: LightClientError) -> ZingolibError { use zingolib::lightclient::error::MigrationError; - let text = e.to_string(); + let text = error_chain_text(&e); match e { LightClientError::SyncLaunchError | LightClientError::SyncNotRunning