diff --git a/AGENTS.md b/AGENTS.md index 0dad96c53..2e9da8f73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,53 +9,23 @@ ## The mobile backend and the UI are separate concerns -The mobile backend is the Rust under `rust/` and the native interfaces to it: -the UniFFI components, the Kotlin and Swift modules that bind them, and the -build systems that produce them. The UI is everything above that boundary: the -React Native screens and components, the theme, the styles, the colours, and -the user-facing copy. - -Keep work on one out of the other. A change to the mobile backend carries no -colour, spacing, typography, or component-styling edit. A UI change carries no -edit to the Rust or its bindings. Where a task genuinely needs both, make them -separate commits and say which is which, so a reviewer can read one without the -other. - -Before you finish backend work, read your own diff and split out anything that -is UI. A styling edit that rides along in a backend diff wastes a reviewer's -attention and gets reverted rather than reviewed. This has already cost the -project once: a batch of colour changes was undone inside a mixnet pull request -because it travelled with unrelated backend work. - -While you are in either half, watch for places where the boundary is muddy. A -component that reaches into the wallet backend, a backend module that formats -prose meant for a person, a native module that decides what a screen shows: -each is worth naming. Propose the separation as its own change rather than -fixing it inline, and say what it would cost. +The mobile backend (Rust under `rust/`, its UniFFI/Kotlin/Swift bindings, and their build systems) and the UI (React Native screens, theme, styles, colours, copy) are separate concerns: keep each change to one side, and where a task needs both, split them into commits labelled backend or UI so a reviewer can read one without the other. Before finishing, scan your own diff for stray edits from the other half, and when the boundary is muddy (a component reaching into the wallet backend, a backend module formatting human prose), name it and propose the separation as its own change rather than fixing it inline. ## Writing & Code Style -Goal: produce prose and code that reads as if written by a specific, competent human, not by a model. The point is naturalness and fit, not looking exhaustive or safe. When in doubt, commit to a choice and keep it short. +Goal: produce prose and code that reads as if written by a specific, competent human, not by a model. The point is naturalness and accuracy, not looking exhaustive or safe. When in doubt, commit to a choice and keep it short. ### Prose #### Economy and precision (adapted from Strunk's Elements of Style) -- Omit needless words. Every word must earn its place. Cut filler phrases entirely: - - "the fact that" → delete or restructure - - "in order to" → "to" - - "due to the fact that" → "because" - - "at this point in time" → "now" - - "has the ability to" → "can" - - "there is X that does Y" → "X does Y" - - "essentially" / "basically" / "fundamentally" → almost always delete - Use the active voice. Prefer "the parser rejects malformed input" over "malformed input is rejected by the parser". Passive voice only when the actor is unknown or irrelevant. - Put statements in positive form. Say what something is, not what it isn't. "The cache is stale" beats "the cache is not up to date". No "not un-" constructions. - Use definite, specific, concrete language. "Sync stalls after 40k blocks" beats "performance degrades under certain conditions". If you have a number, a name, or a mechanism, state it. -- One paragraph, one topic. Don't braid two ideas together and rely on connectors to hold them. - Place emphatic words at the end of the sentence. Don't bury the key claim mid-sentence and trail off with qualifiers. - Don't overstate. Cut intensifiers ("very", "extremely", "incredibly"). When uncertainty is real, hedge precisely ("untested on mainnet"), not vaguely ("may or may not work"). -- Do not explain too much. State the point once. If a sentence adds no new information, delete it. +- Don't be verbose: if you need ":" to explain something, shorten it instead. +- Do not explain too much. State the point once. - Revise by deletion. When tightening prose, the default operation is removal, not substitution. A shorter draft that says the same thing is strictly better. #### Punctuation @@ -117,6 +87,7 @@ without losing meaning, the "so" was decorative. Delete it. ### Code (all languages) +- Never add inline comments. - Comment why, not what. No line-by-line narration of obvious operations. - No tutorial narration ("Now we...", "Step 1:", "First, let's...") and no banner comments (`// ===== HELPERS =====`). - No docstrings that just restate the signature. diff --git a/CONTEXT.md b/CONTEXT.md index b9cce627b..58de9594b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -238,8 +238,7 @@ _Avoid_: mode (names the feature, not the state it reports) **Route**: The network path a mixnet-only surface resolves to, either the Standing Client's tunnel or clearnet. zingolib derives it from the indicator, and the -app never sees it. A price fetch that resolves to clearnet is refused, never -sent. +app never sees it. ## Price surface @@ -256,25 +255,11 @@ clock), so a healthy cadence never dims, ceiling draws included. Staleness is a fact about the value's accuracy, not about fetch mechanics, and it is independent of transport. -**Price consent**: -Selecting Nym, the persisted Mixnet Mode opt-in. It is the single and -only consent for price traffic: the surface fetches on no other -authority, and no fetch failure raises a snackbar. The display currency -chooses what to show, never what may be fetched. -_Avoid_: manual fetch (removed; every fetch is app-initiated); USD -selection as consent (the seeded default authorizes nothing) - -**Cadence**: -The consented fetch rhythm: boot, the Nym selection turning on, and -every gate-open return from the background fetch at once, and each -further fetch follows the last at a uniform random delay of five to ten -minutes. - **Ready follow-up**: The one-shot fetch armed when an unattended fetch is refused during bootstrap; it fires when the Indicator turns `ready` and is dropped on a -`died` or `off` verdict or on the next background transition. A transient -`unknown` is one failed status poll, not a verdict, and does not drop it. +`died` status or on the next background transition. A transient `unknown` +is one failed status poll, not a settled status, and does not drop it. ## Biometric gate diff --git a/__tests__/priceSurface.cue.tsx b/__tests__/priceDisplay.ring.tsx similarity index 80% rename from __tests__/priceSurface.cue.tsx rename to __tests__/priceDisplay.ring.tsx index aaade35f7..1d13193e7 100644 --- a/__tests__/priceSurface.cue.tsx +++ b/__tests__/priceDisplay.ring.tsx @@ -1,12 +1,5 @@ /** - * Evidence tests for the PR 1343 review findings in the staleness cue and - * the display-only ring's accessibility. Each test encodes the behavior - * the finding says the surface should have: it fails on the broken code - * and passes once the finding is fixed. - * - * F8 (ring half): the muted arc must stay distinguishable from the track. - * F9: the display-only ring is not an unnamed disabled tap stop, and the - * staleness cue reaches screen readers as a label. + * The staleness cue and the display-only ring's accessibility. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -69,7 +62,7 @@ const collect = ( (node.children ?? []).forEach(child => collect(child, hits, pick)); }; -test('F8: the stale arc keeps a color of its own, distinct from the track', () => { +test('the stale arc keeps a color of its own, distinct from the track', () => { const staleCtx = makeCtx({ zecPrice: { zecPrice: 33.33, date: Date.now() - 11 * 60_000 }, }); @@ -79,7 +72,7 @@ test('F8: the stale arc keeps a color of its own, distinct from the track', () = expect(ring.props.ringColor).not.toBe(ring.props.trackColor); }); -test('F9: the display-only ring exposes no disabled tap stop', () => { +test('the display-only ring exposes no disabled tap stop', () => { const freshCtx = makeCtx({ zecPrice: { zecPrice: 33.33, date: Date.now() }, }); @@ -94,7 +87,7 @@ test('F9: the display-only ring exposes no disabled tap stop', () => { expect(disabledStops).toEqual([]); }); -test('F9: a stale price reaches screen readers as a label', () => { +test('a stale price reaches screen readers as a label', () => { const staleCtx = makeCtx({ zecPrice: { zecPrice: 33.33, date: Date.now() - 11 * 60_000 }, }); @@ -102,7 +95,7 @@ test('F9: a stale price reaches screen readers as a label', () => { expect(view.getByLabelText('price-ring-stale')).toBeTruthy(); }); -test('F9: a current price reaches screen readers as a label too', () => { +test('a current price reaches screen readers as a label too', () => { const freshCtx = makeCtx({ zecPrice: { zecPrice: 33.33, date: Date.now() }, }); @@ -110,7 +103,7 @@ test('F9: a current price reaches screen readers as a label too', () => { expect(view.getByLabelText('price-ring-live')).toBeTruthy(); }); -test('R4: the ring restarts on every refresh cycle, failed ones included', async () => { +test('the ring restarts on every refresh cycle, failed ones included', async () => { jest.useFakeTimers(); const view = render(fetcherUi(makeCtx())); // every fetch here is refused await jest.advanceTimersByTimeAsync(0); @@ -126,21 +119,21 @@ test('R4: the ring restarts on every refresh cycle, failed ones included', async jest.useRealTimers(); }); -test('R5: a price that never arrived is announced as absent, not stale', async () => { +test('a price that never arrived is announced as absent, not stale', async () => { const view = render(fetcherUi(makeCtx())); // no price has ever existed await waitFor(() => expect(view.getByLabelText('price-ring-none')).toBeTruthy(), ); }); -test('N8: without the Nym consent no ring counts down to nothing', () => { +test('without the Nym opt-in no ring counts down to nothing', () => { const view = render(fetcherUi(makeCtx({ nym: false }))); // A countdown beside a surface that will never fetch misleads; the // unconsented state renders no ring at all. expect(view.queryByTestId('pricefetcher.ring')).toBeNull(); }); -test('P4: a refusing transport hides the ring for the same reason', () => { +test('a refusing transport hides the ring for the same reason', () => { const view = render( fetcherUi( makeCtx({ diff --git a/__tests__/priceSurface.cadence.tsx b/__tests__/priceStore.cadence.tsx similarity index 95% rename from __tests__/priceSurface.cadence.tsx rename to __tests__/priceStore.cadence.tsx index 3aaa9c3da..b6b369561 100644 --- a/__tests__/priceSurface.cadence.tsx +++ b/__tests__/priceStore.cadence.tsx @@ -1,8 +1,5 @@ /** - * Spec tests for the ratified price cadence (ADR 0008, Consequences): - * turning Nym on, a boot, and every gate-open return from the - * background fetch at once, and each further fetch follows the last at - * a uniform random delay of five to ten minutes. + * The ratified price cadence. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, diff --git a/__tests__/priceSurface.storeContract.tsx b/__tests__/priceStore.contract.tsx similarity index 78% rename from __tests__/priceSurface.storeContract.tsx rename to __tests__/priceStore.contract.tsx index 9cc6d82f9..72705b85e 100644 --- a/__tests__/priceSurface.storeContract.tsx +++ b/__tests__/priceStore.contract.tsx @@ -1,21 +1,5 @@ /** - * The store's observable contract: snapshot shape and identity, deps - * writes, the disposition switch, and the display-only ring, - * distilled from the reviews of PR 1343. Each test encodes the - * behavior a finding says the surface should have: it fails on the - * broken code and passes once fixed. - * - * H1: withdrawing the consent takes the ring down with the cadence. - * H2: an entry flight with no armed deadline never reads as full. - * H3: (in Send.priceCta.unit) the USD-entry derived ZEC dims when the - * price is stale. - * H4: the ready follow-up never re-arms itself. - * H5: every status key classifies under the exhaustive disposition. - * H6: the driver writes deps when an input moves, not per render. - * H7: the snapshot carries exactly its four read fields. - * H8: the snapshot keeps its identity between emits. - * H9: no any-casts in the price suites and no null in the store. - * H10: the ring is display-only. + * The store's observable contract. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -108,7 +92,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('H1: withdrawing the consent takes the ring down', async () => { +test('withdrawing the opt-in takes the ring down', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -128,7 +112,7 @@ test('H1: withdrawing the consent takes the ring down', async () => { expect(view.queryByTestId('pricefetcher.ring')).toBeNull(); }); -test('H2: an entry flight with no armed deadline never reads full', async () => { +test('an entry flight with no armed deadline never reads full', async () => { jest.useFakeTimers(); price.mockImplementation(() => new Promise(() => {})); // in flight const setZecPrice = jest.fn(); @@ -147,7 +131,7 @@ test('H2: an entry flight with no armed deadline never reads full', async () => expect(ring.props.startProgress).toBeLessThan(1); }); -test('H4: the ready follow-up never re-arms itself', async () => { +test('the ready follow-up never re-arms itself', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); @@ -168,7 +152,7 @@ test('H4: the ready follow-up never re-arms itself', async () => { expect(price).toHaveBeenCalledTimes(4); }); -test('H5: every status key classifies under the disposition switch', () => { +test('every status key classifies under the disposition switch', () => { const dispositions = MIXNET_STATUS_KEYS.map(key => transportDisposition(key)); dispositions.forEach(d => expect(['refusing', 'possibleBootstrap', 'serving']).toContain(d), @@ -178,7 +162,7 @@ test('H5: every status key classifies under the disposition switch', () => { expect(dispositions).toContain('serving'); }); -test('H6: the driver writes deps when an input moves, not per render', async () => { +test('the driver writes deps when an input moves, not per render', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setDepsSpy = jest.spyOn(priceFetcherStore, 'setDeps'); @@ -198,7 +182,7 @@ test('H6: the driver writes deps when an input moves, not per render', async () setDepsSpy.mockRestore(); }); -test('H7: the snapshot carries exactly its four read fields', () => { +test('the snapshot carries exactly its four read fields', () => { expect(Object.keys(priceFetcherStore.snapshot()).sort()).toEqual([ 'loading', 'nextFetchAt', @@ -207,22 +191,22 @@ test('H7: the snapshot carries exactly its four read fields', () => { ]); }); -test('H8: the snapshot keeps its identity between emits', () => { +test('the snapshot keeps its identity between emits', () => { const first = priceFetcherStore.snapshot(); expect(priceFetcherStore.snapshot()).toBe(first); }); -test('H9: no any-casts in the price suites and no null in the store', () => { +test('no any-casts in the price suites and no null in the store', () => { const suites = [ - 'priceSurface.cadence.tsx', - 'priceSurface.cue.tsx', - 'priceSurface.lifecycle.tsx', - 'priceSurface.trafficGuards.tsx', - 'priceSurface.wedges.tsx', - 'priceSurface.soleConsent.tsx', - 'priceSurface.landings.tsx', - 'priceSurface.storeContract.tsx', - 'priceSurface.recovery.tsx', + 'priceStore.cadence.tsx', + 'priceDisplay.ring.tsx', + 'priceStore.lifecycle.tsx', + 'priceStore.trafficGuards.tsx', + 'priceStore.wedgeGuards.tsx', + 'priceStore.optIn.tsx', + 'priceStore.nativeCall.tsx', + 'priceStore.contract.tsx', + 'priceStore.recovery.tsx', 'Send.priceCta.unit.tsx', 'PriceFetcher.snapshot.tsx', ]; @@ -240,7 +224,7 @@ test('H9: no any-casts in the price suites and no null in the store', () => { expect(new RegExp('\\bnul' + 'l\\b').test(store)).toBe(false); }); -test('H10: the ring is display-only', () => { +test('the ring is display-only', () => { const ringSource = fs.readFileSync( path.join(__dirname, '../components/Components/QuoteRefreshRing.tsx'), 'utf8', diff --git a/__tests__/priceSurface.lifecycle.tsx b/__tests__/priceStore.lifecycle.tsx similarity index 84% rename from __tests__/priceSurface.lifecycle.tsx rename to __tests__/priceStore.lifecycle.tsx index 3ca274dc4..5ac4983ce 100644 --- a/__tests__/priceSurface.lifecycle.tsx +++ b/__tests__/priceStore.lifecycle.tsx @@ -1,18 +1,5 @@ /** - * Evidence tests for the PR 1343 review findings in the price-surface - * store lifecycle. Each test encodes the behavior the finding says the - * surface should have: it fails on the broken code and passes once the - * finding is fixed. - * - * F1: the cold-start entry fetch must not race the deps write. - * F2: observing the store must never count as price-traffic consent. - * F3: an iOS interruption (inactive) is a non-event, and the AppState - * subscription dies with the last mounted fetcher. - * F4: a throwing fetch must not pin `loading` and kill the timer. - * F5: a remounted surface with no price must fetch, not trust a module - * clock that outlived the wallet. - * F6: the ready follow-up never re-arms after a background transition, - * survives an in-flight fetch, and disarms on a non-bootstrap state. + * The price-surface store lifecycle. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -83,8 +70,8 @@ const makeCtx = (over?: Partial): Ctx => ({ ...over, }); -// The production composition: the driver owns the lifecycle, the fetcher -// only displays (pre-driver builds let the fetcher own both). +// The production composition: the driver owns the lifecycle, the +// fetcher only displays. const fetcherUi = (ctx: Ctx, setZecPrice: (p: number, d: number) => void) => ( {PriceTrafficDriver ? : <>} @@ -94,15 +81,14 @@ const fetcherUi = (ctx: Ctx, setZecPrice: (p: number, d: number) => void) => ( const foregroundReturned = () => priceFetcherStore.foregroundReturned(); -// Emulates the deps a previous USD session left behind, so the entry-fetch -// paths run in both the broken and the fixed store. The shape is a -// superset of both eras' Deps. +// Seeds the inputs a previous session left behind, so the entry-fetch +// paths run. const seedDeps = (setZecPrice: (p: number, d: number) => void) => { priceFetcherStore.setDeps({ setZecPrice, mixnetStatusKey: 'mixnet.status.off', nymSelected: true, - marketAvailable: true, + priceFetchable: true, }); }; @@ -139,7 +125,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('F1: a cold start with no price fetches once the surface mounts', async () => { +test('a cold start with no price fetches once the surface mounts', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -153,7 +139,7 @@ test('F1: a cold start with no price fetches once the surface mounts', async () ); }); -test('F2: observing the store snapshot never starts price traffic', async () => { +test('observing the store snapshot never starts price traffic', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); seedDeps(jest.fn()); @@ -165,7 +151,7 @@ test('F2: observing the store snapshot never starts price traffic', async () => expect(price).not.toHaveBeenCalled(); }); -test('F3: an ios interruption neither refetches nor disturbs the surface', async () => { +test('an ios interruption neither refetches nor disturbs the surface', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -181,7 +167,7 @@ test('F3: an ios interruption neither refetches nor disturbs the surface', async expect(price).toHaveBeenCalledTimes(1); }); -test('F3: the AppState subscription dies with the last mounted fetcher', async () => { +test('the AppState subscription dies with the last mounted fetcher', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -193,7 +179,7 @@ test('F3: the AppState subscription dies with the last mounted fetcher', async ( expect(removeSpies[removeSpies.length - 1]).toHaveBeenCalled(); }); -test('F4: a throwing fetch neither pins loading nor kills the timer', async () => { +test('a throwing fetch neither pins loading nor kills the timer', async () => { jest.useFakeTimers(); price .mockRejectedValueOnce(new Error('ffi never settled')) @@ -209,7 +195,7 @@ test('F4: a throwing fetch neither pins loading nor kills the timer', async () = expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)); }); -test('F5: a remounted surface with no price fetches instead of waiting a tick', async () => { +test('a remounted surface with no price fetches instead of waiting a tick', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -227,7 +213,7 @@ test('F5: a remounted surface with no price fetches instead of waiting a tick', expect(price).toHaveBeenCalledTimes(2); }); -test('F6: a background transition drops the follow-up even mid-flight', async () => { +test('a background transition drops the follow-up even mid-flight', async () => { let refuseEntry: (v: { price: number; error: string }) => void = () => {}; price .mockImplementationOnce( @@ -244,7 +230,7 @@ test('F6: a background transition drops the follow-up even mid-flight', async () const view = render(fetcherUi(ctx, setZecPrice)); await waitFor(() => expect(price).toHaveBeenCalledTimes(1)); - fireAppState('background'); // documented: drops the armed follow-up + fireAppState('background'); // background drops the armed follow-up refuseEntry({ price: -1, error: 'refused' }); // the flight lands refused await flush(); const afterFlight = price.mock.calls.length; // both attempts of the entry @@ -255,7 +241,7 @@ test('F6: a background transition drops the follow-up even mid-flight', async () expect(price).toHaveBeenCalledTimes(afterFlight); }); -test('F6: a follow-up arriving mid-flight fires after the flight, not never', async () => { +test('a follow-up arriving mid-flight fires after the flight, not never', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); @@ -287,7 +273,7 @@ test('F6: a follow-up arriving mid-flight fires after the flight, not never', as expect(price.mock.calls.length).toBeGreaterThan(inFlight + 1); }); -test('F6: a real transport verdict disarms the follow-up', async () => { +test('a real transport verdict disarms the follow-up', async () => { price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -305,7 +291,7 @@ test('F6: a real transport verdict disarms the follow-up', async () => { expect(price).toHaveBeenCalledTimes(2); }); -test('R1: a hung fetch releases the surface and recovers once it settles', async () => { +test('a hung fetch releases the surface and recovers once it settles', async () => { jest.useFakeTimers(); let settleLate: (v: { price: number; error: string }) => void = () => {}; price @@ -332,7 +318,7 @@ test('R1: a hung fetch releases the surface and recovers once it settles', async expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)); }); -test('R3: a transport turning ready mid-flight still gets the follow-up', async () => { +test('a transport turning ready mid-flight still gets the follow-up', async () => { let refuseFirst: (v: { price: number; error: string }) => void = () => {}; let refuseSecond: (v: { price: number; error: string }) => void = () => {}; price @@ -369,7 +355,7 @@ test('R3: a transport turning ready mid-flight still gets the follow-up', async expect(price.mock.calls.length).toBeGreaterThan(2); }); -test('R3: a timer refusal during bootstrap arms the follow-up too', async () => { +test('a timer refusal during bootstrap arms the follow-up too', async () => { jest.useFakeTimers(); price .mockResolvedValueOnce({ price: 42, error: '' }) // the boot fetch lands @@ -381,7 +367,7 @@ test('R3: a timer refusal during bootstrap arms the follow-up too', async () => setZecPrice, mixnetStatusKey: 'mixnet.status.bootstrapping', nymSelected: true, - marketAvailable: true, + priceFetchable: true, }); const ctx = makeCtx({ @@ -411,7 +397,7 @@ test('R3: a timer refusal during bootstrap arms the follow-up too', async () => expect(price.mock.calls.length).toBeGreaterThan(afterTimer); }); -test('R6: a return landing inside a flight still produces the return fetch', async () => { +test('a return landing inside a flight still produces the return fetch', async () => { let land: (v: { price: number; error: string }) => void = () => {}; price .mockImplementationOnce( @@ -438,7 +424,7 @@ test('R6: a return landing inside a flight still produces the return fetch', asy expect(price.mock.calls.length).toBeGreaterThanOrEqual(3); }); -test('R7: withdrawing consent mid-flight stops the retry and the write', async () => { +test('withdrawing the opt-in mid-flight stops the retry and the write', async () => { let land: (v: { price: number; error: string }) => void = () => {}; price .mockImplementationOnce( @@ -462,7 +448,7 @@ test('R7: withdrawing consent mid-flight stops the retry and the write', async ( expect(setZecPrice).not.toHaveBeenCalled(); // no write into a dead surface }); -test('R8: rapid app hops inside the cooldown do not multiply fetches', async () => { +test('rapid app hops inside the cooldown do not multiply fetches', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -481,7 +467,7 @@ test('R8: rapid app hops inside the cooldown do not multiply fetches', async () expect(price).toHaveBeenCalledTimes(1); }); -test('R2: without the Nym selection no price traffic exists', async () => { +test('without the Nym selection no price traffic exists', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -497,7 +483,7 @@ test('R2: without the Nym selection no price traffic exists', async () => { expect(price).not.toHaveBeenCalled(); }); -test('R9: a transient unknown poll does not drop the armed follow-up', async () => { +test('a transient unknown poll does not drop the armed follow-up', async () => { price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); diff --git a/__tests__/priceSurface.landings.tsx b/__tests__/priceStore.nativeCall.tsx similarity index 82% rename from __tests__/priceSurface.landings.tsx rename to __tests__/priceStore.nativeCall.tsx index 10b5663e9..b099fa495 100644 --- a/__tests__/priceSurface.landings.tsx +++ b/__tests__/priceStore.nativeCall.tsx @@ -1,22 +1,6 @@ /** - * Flight landings and the cues at their edges: how a fetch's landing - * treats detach, background, parked returns, and the ring beside it, - * distilled from the reviews of PR 1343. Each test encodes the - * behavior a finding says the surface should have: it fails on the - * broken code and passes once fixed. - * - * G1: a market-less surface (offline mode, non-mainnet chain) renders - * no ring at all, per the store's own verdict. - * G2: a bound expiry never retries against the same wedged native call. - * G3: a session detached mid-flight leaves no loading for the next one. - * G4: a price landing while the app is away is recorded, not re-bought. - * G5: a refusal under a transient 'unknown' status arms the follow-up. - * G6: a parked return the landing declines is consumed, never doubled. - * G7: the boot fetch's loading reaches a fetcher subscribed after it. - * G9: a ceiling draw's fetch latency does not dim a healthy cadence. - * G10: the ring fills from coarse wall-clock ticks, not a per-frame - * animation. - * (G8, the Send/Confirm pinning repair, lives in Send.priceCta.unit.) + * The native price call in flight, on timeout, on detach, and on + * resolve or reject. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -116,7 +100,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('G1: a market-less surface renders no ring at all', async () => { +test('a market-less surface renders no ring at all', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -138,7 +122,7 @@ test('G1: a market-less surface renders no ring at all', async () => { expect(view.queryByTestId('pricefetcher.ring')).toBeNull(); }); -test('G2: a bound expiry never retries against the same wedged call', async () => { +test('a bound expiry never retries against the same wedged call', async () => { jest.useFakeTimers(); price.mockImplementation(() => new Promise(() => {})); // wedged forever const setZecPrice = jest.fn(); @@ -154,7 +138,7 @@ test('G2: a bound expiry never retries against the same wedged call', async () = expect(price).toHaveBeenCalledTimes(1); }); -test('G3: a session detached mid-flight leaves no loading behind', async () => { +test('a session detached mid-flight leaves no loading behind', async () => { jest.useFakeTimers(); price .mockImplementationOnce(() => new Promise(() => {})) // wedged flight @@ -175,7 +159,7 @@ test('G3: a session detached mid-flight leaves no loading behind', async () => { expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)); }); -test('G4: a price landing while the app is away is recorded', async () => { +test('a price landing while the app is away is recorded', async () => { jest.useFakeTimers(); let land: (v: { price: number; error: string }) => void = () => {}; price.mockImplementationOnce( @@ -199,7 +183,7 @@ test('G4: a price landing while the app is away is recorded', async () => { expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)); }); -test("G5: a refusal under a transient 'unknown' arms the follow-up", async () => { +test("a refusal under a transient 'unknown' arms the follow-up", async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); @@ -219,7 +203,7 @@ test("G5: a refusal under a transient 'unknown' arms the follow-up", async () => expect(price.mock.calls.length).toBeGreaterThan(refusedUnderUnknown); }); -test('G6: a parked return the landing declines is consumed, not doubled', async () => { +test('a parked return the landing declines is consumed, not doubled', async () => { jest.useFakeTimers(); let land: (v: { price: number; error: string }) => void = () => {}; price @@ -252,7 +236,7 @@ test('G6: a parked return the landing declines is consumed, not doubled', async expect(price).toHaveBeenCalledTimes(3); }); -test('G7: the boot fetch shows the first-fetch spinner', async () => { +test('the boot fetch shows the first-fetch spinner', async () => { jest.useFakeTimers(); price.mockImplementation(() => new Promise(() => {})); // in flight const setZecPrice = jest.fn(); @@ -267,7 +251,7 @@ test('G7: the boot fetch shows the first-fetch spinner', async () => { expect(view.UNSAFE_queryByType(ActivityIndicator)).toBeTruthy(); }); -test('G9: a ceiling draw plus fetch latency does not dim', () => { +test('a ceiling draw plus fetch latency does not dim', () => { const withinHeadroom = Date.now() - (10 * 60_000 + 10_000); const { result: healthy } = renderHook(() => usePriceStale(withinHeadroom)); expect(healthy.current).toBe(false); @@ -277,7 +261,7 @@ test('G9: a ceiling draw plus fetch latency does not dim', () => { expect(slipped.current).toBe(true); }); -test('G10: the ring fills from coarse ticks, not a per-frame animation', async () => { +test('the ring fills from coarse ticks, not a per-frame animation', async () => { jest.useFakeTimers(); const { Animated } = require('react-native'); const timingSpy = jest.spyOn(Animated, 'timing'); @@ -290,7 +274,7 @@ test('G10: the ring fills from coarse ticks, not a per-frame animation', async ( durationMs={60_000} resetKey={1} accessibilityLabel="ring" - testID="landings.ring" + testID="ring" />, ); await jest.advanceTimersByTimeAsync(30_000); diff --git a/__tests__/priceSurface.soleConsent.tsx b/__tests__/priceStore.optIn.tsx similarity index 80% rename from __tests__/priceSurface.soleConsent.tsx rename to __tests__/priceStore.optIn.tsx index d027ee85b..6ea893d54 100644 --- a/__tests__/priceSurface.soleConsent.tsx +++ b/__tests__/priceStore.optIn.tsx @@ -1,21 +1,5 @@ /** - * The Nym selection as the single and only price-traffic consent - * (ADR 0008): one verdict test per finding of Juanky's review of - * PR 1343, plus one that review surfaced. Each test encodes the - * behavior the surface should have: it fails on the broken code and - * passes once the finding is fixed. - * - * J1: the Nym consent alone starts the cadence; the display currency - * never gates traffic. This pins the ratified ADR 0008 behavior - * Juanky asks about: a ZEC-display wallet with the consent fetches - * a price nothing renders. The test passes today; if the answer to - * "intended?" is no, this is the test to invert. - * J2: when the ring's cycle-keyed fill completes, a refresh really is - * due: no path re-arms a full timer without restarting the ring. - * J3: a return parked on an in-flight fetch still counts as the last - * return-triggered fetch, so the next hop is rate-bound. - * R1: a re-render behind the closed foreground gate emits no traffic; - * only foregroundReturned may, as the store's own comment states. + * The Nym opt-in that starts price traffic. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -97,7 +81,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('J1: with the consent a ZEC-display wallet still fetches every tick', async () => { +test('with the opt-in a ZEC-display wallet still fetches every tick', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -112,7 +96,7 @@ test('J1: with the consent a ZEC-display wallet still fetches every tick', async expect(price.mock.calls.length).toBeGreaterThanOrEqual(2); // the cadence, display or not }); -test('J2: a full ring always means a refresh really is due', async () => { +test('a full ring always means a refresh really is due', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -138,7 +122,7 @@ test('J2: a full ring always means a refresh really is due', async () => { expect(rearmed.nextFetchAt).toBe(Date.now() + rearmed.nextFetchDelayMs); }); -test('J3: a return parked on a flight still arms the hop rate bound', async () => { +test('a return parked on a flight still arms the hop rate bound', async () => { jest.useFakeTimers(); let land: (v: { price: number; error: string }) => void = () => {}; price @@ -174,7 +158,7 @@ test('J3: a return parked on a flight still arms the hop rate bound', async () = expect(price).toHaveBeenCalledTimes(4); // rate-bound, no fifth call }); -test('R1: a re-render behind the closed gate emits no traffic', async () => { +test('a re-render behind the closed gate emits no traffic', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); diff --git a/__tests__/priceSurface.recovery.tsx b/__tests__/priceStore.recovery.tsx similarity index 84% rename from __tests__/priceSurface.recovery.tsx rename to __tests__/priceStore.recovery.tsx index d60a382cf..8ac3fa84d 100644 --- a/__tests__/priceSurface.recovery.tsx +++ b/__tests__/priceStore.recovery.tsx @@ -1,20 +1,5 @@ /** - * Outage recovery and staleness: a lost market, a flapping transport, - * a rejected native call, and the stale crossings and muting beside - * them, distilled from the reviews of PR 1343. Each test encodes the - * behavior a finding says the surface should have: it fails on the - * broken code and passes once fixed. - * - * R1: a lost market takes the cadence down now, and publishes it. - * R2: a market outage wedges no past deadline, and its recovery past - * the cadence floor fetches its entry at once. - * R3: a flapping transport rides the cadence instead of fetching a - * pair per reconnect. - * R4: a rejected native price call reads as a refusal (retry and - * follow-up arm included), never as a silent vanish. - * R5: no stale crossing outlives its consumers. - * R6: two prices each keep their own stale crossing. - * R7: the stale-or-absent muting rule has one spelling, usePriceHealth. + * Outage recovery and staleness. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -106,7 +91,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('R1: a lost market takes the cadence down now, and publishes it', async () => { +test('a lost market takes the cadence down now, and publishes it', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -137,7 +122,7 @@ test('R1: a lost market takes the cadence down now, and publishes it', async () unsubscribe(); }); -test('R2: no wedged deadline across an outage, and the recovery entry fires', async () => { +test('no wedged deadline across an outage, and the recovery entry fires', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -171,7 +156,7 @@ test('R2: no wedged deadline across an outage, and the recovery entry fires', as expect(priceFetcherStore.snapshot().nextFetchAt).toBeGreaterThan(Date.now()); }); -test('R3: a flapping transport rides the cadence, one fetch per window', async () => { +test('a flapping transport rides the cadence, one fetch per window', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -198,7 +183,7 @@ test('R3: a flapping transport rides the cadence, one fetch per window', async ( expect(price.mock.calls.length).toBeGreaterThan(1); }); -test('R4: a rejected native call reads as a refusal, not a vanish', async () => { +test('a rejected native call reads as a refusal, not a vanish', async () => { jest.useFakeTimers(); // A missing native member rejects instead of resolving a sentinel. price.mockRejectedValue(new Error('zecPriceInfo is not a function')); @@ -217,7 +202,7 @@ test('R4: a rejected native call reads as a refusal, not a vanish', async () => expect(price).toHaveBeenCalledTimes(4); }); -test('R5: no stale crossing outlives its consumers', () => { +test('no stale crossing outlives its consumers', () => { jest.useFakeTimers(); const timersBefore = jest.getTimerCount(); const fresh = renderHook(() => usePriceStale(Date.now() - 1_000)); @@ -227,7 +212,7 @@ test('R5: no stale crossing outlives its consumers', () => { expect(jest.getTimerCount()).toBe(timersBefore); }); -test('R6: two prices each keep their own stale crossing', () => { +test('two prices each keep their own stale crossing', () => { jest.useFakeTimers(); const older = Date.now() - PRICE_STALE_MS + 5_000; // crosses in five seconds const newer = Date.now() - 1_000; @@ -243,7 +228,7 @@ test('R6: two prices each keep their own stale crossing', () => { expect(first.result.current).toBe(true); }); -test('R7: the muting rule has one spelling, usePriceHealth', () => { +test('the muting rule has one spelling, usePriceHealth', () => { const sites = [ '../components/Components/PriceFetcher.tsx', '../components/Components/CurrencyAmount.tsx', diff --git a/__tests__/priceSurface.trafficGuards.tsx b/__tests__/priceStore.trafficGuards.tsx similarity index 79% rename from __tests__/priceSurface.trafficGuards.tsx rename to __tests__/priceStore.trafficGuards.tsx index bebe36ff6..8d3845fab 100644 --- a/__tests__/priceSurface.trafficGuards.tsx +++ b/__tests__/priceStore.trafficGuards.tsx @@ -1,19 +1,5 @@ /** - * What may start price traffic: the guards on entries, consent, - * transport, and the foreground gate, distilled from the reviews of - * PR 1343. Each test encodes the behavior a finding says the surface - * should have: it fails on the broken code and passes once fixed. - * - * N2: the attach/entry path is rate-limited against remount storms. - * N3: a withdrawn Nym consent stops the mid-flight retry and the write. - * N4: a return shortly after a FAILED fetch still fetches. - * N5: an 'off' or 'died' transport pauses the cadence instead of feeding - * it refusals forever, and a recovering status resumes it. - * N6: price traffic belongs to the wallet session (the driver), not to - * whichever currency the screens happen to display. - * N9: a wedged native call is never multiplied: one orphan, reused. - * N10: the raw AppState 'active' event fetches nothing; the foreground - * gate's opening is what triggers the return fetch. + * What may start price traffic. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -38,8 +24,18 @@ import { getZecPrice } from '../app/walletBackend'; import { INITIAL_MIXNET_VIEW, OFF_MIXNET_VIEW, + MixnetView, } from '../app/walletBackend/transforms/mixnetPresenter'; +const DIED_VIEW: MixnetView = { + statusKey: 'mixnet.status.died', + socks5Addr: null, + narration: null, + sendBlocked: true, + recovery: 'reenable', + reconnecting: false, +}; + const price = getZecPrice as jest.MockedFunction; type Ctx = typeof defaultAppContextLoaded; @@ -77,7 +73,7 @@ const seedDeps = (setZecPrice: (p: number, d: number) => void) => { setZecPrice, mixnetStatusKey: 'mixnet.status.unknown', nymSelected: true, - marketAvailable: true, + priceFetchable: true, }); }; @@ -113,7 +109,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('N2: remounting display fetchers starts no new fetch', async () => { +test('remounting display fetchers starts no new fetch', async () => { // The driver owns the session; screens mount and unmount fetchers // freely (a settings toggle, a navigation) and none of that is a // boot. Only a driver detach ends the session, and the next attach @@ -132,7 +128,7 @@ test('N2: remounting display fetchers starts no new fetch', async () => { expect(price).toHaveBeenCalledTimes(2); }); -test('N3: a withdrawn consent stops the mid-flight retry', async () => { +test('a withdrawn opt-in stops the mid-flight retry', async () => { let land: (v: { price: number; error: string }) => void = () => {}; price .mockImplementationOnce( @@ -155,7 +151,7 @@ test('N3: a withdrawn consent stops the mid-flight retry', async () => { expect(price).toHaveBeenCalledTimes(1); // no retry after the withdrawal }); -test('N4: a return shortly after a failed fetch still fetches', async () => { +test('a return shortly after a failed fetch still fetches', async () => { price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -172,16 +168,16 @@ test('N4: a return shortly after a failed fetch still fetches', async () => { expect(price.mock.calls.length).toBeGreaterThan(2); }); -test('N5: an off transport pauses the cadence until the status recovers', async () => { +test('a died transport pauses the cadence until the status recovers', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); - // Nym consent held, mixnet session-disabled: the backend refuses every - // price fetch by the route rule, so attempts are pure waste. + // Nym consent held, mixnet died: the backend refuses every price fetch + // by the route rule, so attempts are pure waste. const view = render( - surfaceUi(makeCtx({ mixnetView: OFF_MIXNET_VIEW }), setZecPrice), + surfaceUi(makeCtx({ mixnetView: DIED_VIEW }), setZecPrice), ); await jest.advanceTimersByTimeAsync(61_000); expect(price).not.toHaveBeenCalled(); @@ -193,7 +189,34 @@ test('N5: an off transport pauses the cadence until the status recovers', async expect(price).toHaveBeenCalled(); }); -test('N6: a consented wallet fetches regardless of the displayed currency', async () => { +test('a switch-off fetches the price over clearnet', async () => { + price.mockResolvedValue({ price: 42, error: '' }); + const setZecPrice = jest.fn(); + + render( + surfaceUi( + makeCtx({ nym: false, mixnetView: OFF_MIXNET_VIEW }), + setZecPrice, + ), + ); + await waitFor(() => + expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)), + ); +}); + +test('opting in with the transport still off emits no clearnet fetch', async () => { + price.mockResolvedValue({ price: 42, error: '' }); + const setZecPrice = jest.fn(); + + render( + surfaceUi(makeCtx({ nym: true, mixnetView: OFF_MIXNET_VIEW }), setZecPrice), + ); + await flush(); + await flush(); + expect(price).not.toHaveBeenCalled(); +}); + +test('an opted-in wallet fetches regardless of the displayed currency', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); @@ -207,7 +230,7 @@ test('N6: a consented wallet fetches regardless of the displayed currency', asyn ); }); -test('N9: a wedged native call is reused, never multiplied', async () => { +test('a wedged native call is reused, never multiplied', async () => { jest.useFakeTimers(); price.mockImplementation(() => new Promise(() => {})); // wedged forever const setZecPrice = jest.fn(); @@ -220,7 +243,7 @@ test('N9: a wedged native call is reused, never multiplied', async () => { expect(price).toHaveBeenCalledTimes(1); }); -test('N10: the raw active event fetches nothing; the opened gate does', async () => { +test('the raw active event fetches nothing; the opened gate does', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); diff --git a/__tests__/priceSurface.wedges.tsx b/__tests__/priceStore.wedgeGuards.tsx similarity index 84% rename from __tests__/priceSurface.wedges.tsx rename to __tests__/priceStore.wedgeGuards.tsx index e50083c91..5b8e13af1 100644 --- a/__tests__/priceSurface.wedges.tsx +++ b/__tests__/priceStore.wedgeGuards.tsx @@ -1,19 +1,6 @@ /** - * Wedged timers and wedged native calls, and the gates and rate - * bounds that keep them from re-forming, distilled from the reviews - * of PR 1343. Each test encodes the behavior a finding says the - * surface should have: it fails on the broken code and passes once - * fixed. - * - * P1: a tick that fires into a refusing window must not wedge the timer. - * P2: a wedged native call is retired after its TTL, so the store can - * issue a fresh request again. - * P3: the bare 'active' event arms no cadence; the opened gate does. - * P6: no market, no traffic: offline mode and non-mainnet chains fetch - * nothing, consent or not. - * P7: repeated returns during a failure window are rate-bound. - * P9: a mid-flight consent withdrawal leaves no dangling timer, and a - * re-grant restarts the cadence. + * Wedged timers and wedged native calls, and the gates and rate bounds + * that keep them from re-forming. */ jest.mock('../app/walletBackend', () => ({ __esModule: true, @@ -115,7 +102,7 @@ afterEach(() => { jest.useRealTimers(); }); -test('P1: a tick fired into a refusing window never wedges the cadence', async () => { +test('a tick fired into a refusing window never wedges the cadence', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -156,7 +143,7 @@ test('P1: a tick fired into a refusing window never wedges the cadence', async ( expect(price.mock.calls.length).toBeGreaterThan(1); }); -test('P2: a wedged native call retires after its TTL and a fresh one runs', async () => { +test('a wedged native call retires after its TTL and a fresh one runs', async () => { jest.useFakeTimers(); price .mockImplementationOnce(() => new Promise(() => {})) // wedged forever @@ -172,7 +159,7 @@ test('P2: a wedged native call retires after its TTL and a fresh one runs', asyn expect(setZecPrice).toHaveBeenCalledWith(42, expect.any(Number)); }); -test('P3: the bare active event arms no cadence behind the gate', async () => { +test('the bare active event arms no cadence behind the gate', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -191,7 +178,7 @@ test('P3: the bare active event arms no cadence behind the gate', async () => { expect(price).toHaveBeenCalledTimes(2); }); -test('P6: no market, no traffic: offline and non-mainnet fetch nothing', async () => { +test('no market, no traffic: offline and non-mainnet fetch nothing', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -218,7 +205,7 @@ test('P6: no market, no traffic: offline and non-mainnet fetch nothing', async ( expect(price).not.toHaveBeenCalled(); }); -test('P7: repeated returns during a failure window are rate-bound', async () => { +test('repeated returns during a failure window are rate-bound', async () => { price.mockResolvedValue({ price: -1, error: 'refused' }); const setZecPrice = jest.fn(); @@ -238,7 +225,7 @@ test('P7: repeated returns during a failure window are rate-bound', async () => expect(price).toHaveBeenCalledTimes(4); }); -test('P9: a mid-flight consent withdrawal leaves no wedge for a re-grant', async () => { +test('a mid-flight opt-in withdrawal leaves no wedge for a re-grant', async () => { jest.useFakeTimers(); let land: (v: { price: number; error: string }) => void = () => {}; price diff --git a/app/walletBackend/transforms/mixnetPresenter.ts b/app/walletBackend/transforms/mixnetPresenter.ts index 18898a4d1..337715ebb 100644 --- a/app/walletBackend/transforms/mixnetPresenter.ts +++ b/app/walletBackend/transforms/mixnetPresenter.ts @@ -20,21 +20,21 @@ export const MIXNET_STATUS_KEYS: readonly MixnetStatusKey[] = [ 'mixnet.status.unknown', ]; -/** How the transport disposes a mixnet-only fetch: refuses it, might still be bootstrapping, or serves it. */ +/** How the transport disposes a price fetch. */ export type MixnetTransportDisposition = 'refusing' | 'possibleBootstrap' | 'serving'; -/** Classifies a status key exhaustively, so a new indicator breaks this build instead of silently passing as servable. */ +/** Classifies a status key exhaustively. */ export function transportDisposition( key: MixnetStatusKey, ): MixnetTransportDisposition { switch (key) { - case 'mixnet.status.off': case 'mixnet.status.died': return 'refusing'; case 'mixnet.status.bootstrapping': case 'mixnet.status.unknown': return 'possibleBootstrap'; + case 'mixnet.status.off': case 'mixnet.status.ready': return 'serving'; } @@ -88,11 +88,10 @@ export const OFF_MIXNET_VIEW: MixnetView = { /** * Derives the screen-facing view from the typed reports. * - * Pure function — no side effects. The fail-closed invariant lives here in + * Pure function. The fail-closed invariant lives here in * app form: a failure report blocks sending exactly as `bootstrapping` and * `died` do, because an unknowable transport must never be treated as - * consented clearnet (ADR 0011; the wallet core enforces the same rule — - * this projection only keeps the UI honest about it). + * consented clearnet. */ export function deriveMixnetView( status: MixnetStatusReport, diff --git a/components/Components/CurrencyAmount.tsx b/components/Components/CurrencyAmount.tsx index b7f9db2d8..6083c0581 100644 --- a/components/Components/CurrencyAmount.tsx +++ b/components/Components/CurrencyAmount.tsx @@ -16,7 +16,7 @@ type CurrencyAmountProps = { privacy?: boolean; selectable?: boolean; // The live price's date: a conversion older than the stale threshold - // dims (ADR 0008). Omit for historical conversions, which never dim. + // dims. Omit for historical conversions, which never dim. priceDate?: number; }; diff --git a/components/Components/PriceFetcher.tsx b/components/Components/PriceFetcher.tsx index caa474f43..1645b86f4 100644 --- a/components/Components/PriceFetcher.tsx +++ b/components/Components/PriceFetcher.tsx @@ -14,8 +14,8 @@ import { /** * Owns the price surface's traffic for the wallet session: LoadedApp - * mounts exactly one, so fetching follows the session and the Nym - * consent, never whichever currency the screens happen to display. + * mounts exactly one, so fetching follows the session and its consent, + * never whichever currency the screens happen to display. */ export const PriceTrafficDriver: React.FunctionComponent = () => { const context = useContext(ContextAppLoaded); @@ -24,13 +24,12 @@ export const PriceTrafficDriver: React.FunctionComponent = () => { const mixnetStatusKey = mixnetView ? mixnetView.statusKey : 'mixnet.status.unknown'; - // Offline mode and non-mainnet chains have no usable ZEC/USD market, - // so they never justify traffic, consent or not. - const marketAvailable = + // The price source is fetchable only on mainnet with a live server. + const priceFetchable = selectServer !== SelectServerEnum.offline && info.chainName === ChainNameEnum.mainChainName; - // Deps first, so the attach below always finds them; the dependency + // PriceInputs first, so the attach below always finds them; the dependency // list keeps the store's fetch-decision path off the render loop. The // statusKey write is also what fires an armed ready follow-up. useEffect(() => { @@ -38,17 +37,17 @@ export const PriceTrafficDriver: React.FunctionComponent = () => { setZecPrice, mixnetStatusKey, nymSelected: nym, - marketAvailable, + priceFetchable, }); - }, [setZecPrice, mixnetStatusKey, nym, marketAvailable]); + }, [setZecPrice, mixnetStatusKey, nym, priceFetchable]); useEffect(() => priceFetcherStore.attach(), []); return null; }; -// Display-only (ADR 0008): the ring reports the shared store's cadence -// and the price's health; it starts nothing and offers no tap. +// Display-only: the ring reports the shared store's cadence and the +// price's health; it starts nothing and offers no tap. type PriceFetcherProps = { textBefore?: string; backgroundColor?: string; diff --git a/components/Components/QuoteRefreshRing.tsx b/components/Components/QuoteRefreshRing.tsx index 5c8ce26a4..d931d2621 100644 --- a/components/Components/QuoteRefreshRing.tsx +++ b/components/Components/QuoteRefreshRing.tsx @@ -15,7 +15,7 @@ const RING_ARC_STEP = 1 / 64; /** * Display-only countdown ring: a refresh glyph wrapped in a ring that * fills clockwise to full over `durationMs` and restarts whenever - * `resetKey` changes. It offers no tap; ADR 0008 removed the manual + * `resetKey` changes. It offers no tap: the surface has no manual * fetch, so the ring only reports the cadence that is running. */ type QuoteRefreshRingProps = { diff --git a/components/Components/priceFetcherStore.ts b/components/Components/priceFetcherStore.ts index 6ac4320ac..99beaa291 100644 --- a/components/Components/priceFetcherStore.ts +++ b/components/Components/priceFetcherStore.ts @@ -9,42 +9,41 @@ import { /** * Shared, singleton state for the price surface's fetch lifecycle. * - * ADR 0008: the price surface has no manual fetch, and the Nym opt-in is - * the single and only consent for price traffic. The PriceTrafficDriver - * mounted by LoadedApp attaches here for the wallet session and carries - * the consent bit in its deps, so the displayed currency never decides - * what may be fetched; PriceFetcher and usePriceFetcherStore only - * observe. The store owns the whole lifecycle: + * The price surface has no manual fetch. Consent is the Nym opt-in or an + * explicit switch-off, which serves the fetch over clearnet. The + * PriceTrafficDriver mounted by LoadedApp attaches here for the wallet + * session and carries the consent bit in its deps, so the displayed + * currency never decides what may be fetched; PriceFetcher and + * usePriceFetcherStore only observe. The store owns the whole lifecycle: * - ONE auto-refresh timer while the driver is attached, the gate-open * foreground holds, and the transport is not a refusing verdict - * ('off' or 'died'): each fetch schedules the next at a uniform - * random delay of five to ten minutes. A background transition - * pauses the cadence, and iOS 'inactive' (Control Center, the app's - * own Face ID sheet) is a non-event. - * - A fetch at every entry: attach (boot), the Nym consent turning - * on, and LoadedApp's foreground gate opening after a real - * background return (never on the raw AppState event, which races a - * locked, unauthenticated wallet). Entry fetches inside the burst - * cooldown of the last start or last success yield to the cadence. + * ('died'): each fetch schedules the next at a uniform random delay + * of five to ten minutes. A background transition pauses the + * cadence, and iOS 'inactive' (Control Center, the app's own Face ID + * sheet) is a non-event. + * - A fetch at every entry: attach (boot), the consent turning on, and + * LoadedApp's foreground gate opening after a real background return + * (never on the raw AppState event, which races a locked, + * unauthenticated wallet). Entry fetches inside the burst cooldown of + * the last start or last success yield to the cadence. * - The ready follow-up: an unattended fetch refused during bootstrap * arms a one-shot fetch that fires when the Indicator turns ready. - * It is dropped on a background transition and on a 'died' or 'off' - * verdict, survives an in-flight fetch, and a transient 'unknown' - * status poll never drops it. + * It is dropped on a background transition and on a 'died' verdict, + * survives an in-flight fetch, and a transient 'unknown' status poll + * never drops it. * - No snackbars. A failure leaves the last price standing; the stale - * cue (see usePriceStale) is the only failure signal. + * cue is the only failure signal. */ -// ADR 0008 cadence: each fetch schedules the next at a uniform draw -// from this window. +// Each fetch schedules the next at a uniform draw from this window. export const PRICE_REFRESH_MIN_MS = 5 * 60_000; export const PRICE_REFRESH_MAX_MS = 10 * 60_000; // The JS-side bound on one native price call: a wedged FFI promise must // never pin `loading` for the process lifetime. const PRICE_FETCH_TIMEOUT_MS = 30_000; -// A price older than this wall-clock age is stale (CONTEXT.md: Stale -// price): the cadence ceiling plus one fetch bound of headroom, so a -// healthy cadence never dims, not even on a ceiling draw's latency. +// A price older than this wall-clock age is stale: the cadence ceiling +// plus one fetch bound of headroom, so a healthy cadence never dims, +// not even on a ceiling draw's latency. export const PRICE_STALE_MS = PRICE_REFRESH_MAX_MS + PRICE_FETCH_TIMEOUT_MS; // Rapid remounts and app hops must not multiply fetches. const FETCH_BURST_COOLDOWN_MS = 5_000; @@ -52,16 +51,14 @@ const FETCH_BURST_COOLDOWN_MS = 5_000; // request can run: bounded orphans, never a corpse cached forever. const NATIVE_CALL_TTL_MS = 5 * 60_000; -type Deps = { +type PriceInputs = { setZecPrice: (price: number, date: number) => void; // The live Indicator key; 'mixnet.status.unknown' before a publication. mixnetStatusKey: MixnetStatusKey; - // The persisted Nym opt-in: the sole consent for price traffic. The - // display currency chooses what to show, never what may be fetched. + // The persisted Nym opt-in. nymSelected: boolean; - // Whether a market exists to ask: a live server selection on mainnet. - // Offline mode and other chains have no usable ZEC/USD price. - marketAvailable: boolean; + // Whether the price source is fetchable for the current Zcash network. + priceFetchable: boolean; }; // What every observer reads: identity-stable between real changes, as @@ -104,7 +101,7 @@ let followUpArmed = false; // A return that landed while a fetch was in flight; honored when the // flight lands so a real return always produces its fetch. let entryPending = false; -let deps: Deps | undefined; +let deps: PriceInputs | undefined; let cadence: Cadence = { state: 'idle' }; let attachCount = 0; // Bumped when a session detaches: a flight that outlived its session @@ -156,14 +153,23 @@ function transportRefuses(): boolean { function surfaceMayFetch(): boolean { return ( deps !== undefined && - deps.nymSelected && - deps.marketAvailable && + priceTrafficConsented() && + deps.priceFetchable && attachCount > 0 && !appAway && !transportRefuses() ); } +function priceTrafficConsented(): boolean { + return ( + deps !== undefined && + (deps.nymSelected + ? deps.mixnetStatusKey !== 'mixnet.status.off' + : deps.mixnetStatusKey === 'mixnet.status.off') + ); +} + function scheduleAuto(): void { clearAuto(); if (!surfaceMayFetch()) return; @@ -369,23 +375,15 @@ function onAppStateChange(next: string): void { export const priceFetcherStore = { /** Keep the latest context-bound callbacks (same across all instances). */ - setDeps(d: Deps): void { + setDeps(d: PriceInputs): void { deps = d; - if (!d.nymSelected) { - // The consent was withdrawn (or never given): stop the cadence. + pump(); + if (!surfaceMayFetch()) { clearAuto(); followUpArmed = false; entryPending = false; return; } - pump(); - if (transportRefuses() || !d.marketAvailable) { - // A refusing verdict or a lost market ends the cadence now, not - // at the next tick, and clearAuto's emit takes the ring down - // with it. - clearAuto(); - return; - } entryOrSchedule(); }, /** Register the wallet session's price surface, returning the detach cleanup. */ diff --git a/docs/adr/0008-the-price-surface-has-no-manual-fetch.md b/docs/adr/0008-the-price-surface-has-no-manual-fetch.md deleted file mode 100644 index d28d7e4b2..000000000 --- a/docs/adr/0008-the-price-surface-has-no-manual-fetch.md +++ /dev/null @@ -1,48 +0,0 @@ -# The price surface has no manual fetch - -The price surface used to start on a user tap: in advanced mode the first -tap raised a confirm dialog, and that gesture was the consent for all -later price traffic. We removed the tap entirely. Selecting Nym (the -persisted Mixnet Mode opt-in) is now the single and only consent for -price traffic, the surface is display-only, and no fetch failure raises a -snackbar. Staleness is the only failure signal: a price older than the -cadence's longest draw plus one fetch bound (ten minutes and thirty -seconds) dims, and recovery arrives from the refresh timer or the ready -follow-up, never from a gesture. - -We chose this because the tap carried most of the surface's complexity -(the confirm dialog, the cooldown and anti-spam gate, the idle and -spinner states, the error toasts), and because no other setting expresses -a real consent: USD is the seeded default currency, so it authorizes -nothing for a user who never chose it. The Nym selection is a deliberate -opt-in, default off and sticky, and over the mixnet the privacy exposure -of an unattended fetch is bounded by the Route rule: a fetch that -resolves to clearnet is refused, never sent. - -## Considered options - -Keeping the tap as a consent and recovery gesture, relocating the consent -dialog into the currency setting while dropping only the tap, and -treating USD selection as the consent were all rejected: the first -preserves the complexity this decision removes, the second keeps a second -consent point for a single setting, and the third is hollow for the -default case, since a fresh install carries USD without any choice. - -## Consequences - -A user cannot force a refresh: a stale price waits for the next timer -tick or the ready follow-up. Auto-refresh arms whenever the wallet -session holds the Nym selection in the foreground. A wallet without that -selection emits no price traffic at all: its USD rows render without a -fetched value, and enabling Mixnet Mode is what turns the price surface -on. Consent alone is also not enough where no market exists: offline -mode and non-mainnet chains never fetch, since no usable ZEC/USD price -exists for them. - -The consented cadence is fixed: turning the Nym selection on fetches at -once, and so do a boot and every return from the background whose -foreground gate opens. Each further fetch follows the last at a uniform -random delay of five to ten minutes. Traffic follows the consent, never -the display: a wallet showing ZEC amounts fetches on the same cadence as -one showing USD, so the price is warm whenever the display wants it and -the traffic pattern reveals nothing about what the user looks at. diff --git a/package.json b/package.json index fd90891a8..902b4b6cf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "android:prod": "react-native run-android --mode prodDebug", - "android:beta": "react-native run-android --mode betaDebug", + "android:beta": "react-native run-android --mode betaDebug --appId org.ZingoLabs.Zingo.Beta --main-activity org.ZingoLabs.Zingo.MainActivity", "ios": "react-native run-ios", "start": "react-native start", "storybook:generate": "tsx .storybook/generate.mts", diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7edae3f12..358081ae7 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2310,7 +2310,7 @@ dependencies = [ [[package]] name = "pepper-sync" version = "0.5.0" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "bip32", "byteorder", @@ -4738,7 +4738,7 @@ dependencies = [ [[package]] name = "zcash_local_net" version = "0.7.0" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=537f84d3d81b228c06ae82365f306ff364e164da#537f84d3d81b228c06ae82365f306ff364e164da" +source = "git+https://github.com/zingolabs/infrastructure.git?rev=89cf104a2416144d1bbbae30aca02bec062d1492#89cf104a2416144d1bbbae30aca02bec062d1492" dependencies = [ "hex", "reqwest", @@ -5024,12 +5024,12 @@ dependencies = [ [[package]] name = "zingo-consensus" version = "0.1.0" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=537f84d3d81b228c06ae82365f306ff364e164da#537f84d3d81b228c06ae82365f306ff364e164da" +source = "git+https://github.com/zingolabs/infrastructure.git?rev=89cf104a2416144d1bbbae30aca02bec062d1492#89cf104a2416144d1bbbae30aca02bec062d1492" [[package]] name = "zingo-memo" version = "0.1.1" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "zcash_address", "zcash_encoding", @@ -5040,7 +5040,7 @@ dependencies = [ [[package]] name = "zingo-net-diag" version = "0.1.0" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "serde", ] @@ -5048,7 +5048,7 @@ dependencies = [ [[package]] name = "zingo-netutils" version = "5.0.1" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "http", "hyper-util", @@ -5070,7 +5070,7 @@ dependencies = [ [[package]] name = "zingo-price" version = "0.1.0" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "byteorder", "serde", @@ -5084,7 +5084,7 @@ dependencies = [ [[package]] name = "zingo-status" version = "0.2.1" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "byteorder", "zcash_protocol", @@ -5110,12 +5110,12 @@ dependencies = [ [[package]] name = "zingo_test_vectors" version = "0.0.1" -source = "git+https://github.com/zingolabs/infrastructure.git?rev=537f84d3d81b228c06ae82365f306ff364e164da#537f84d3d81b228c06ae82365f306ff364e164da" +source = "git+https://github.com/zingolabs/infrastructure.git?rev=89cf104a2416144d1bbbae30aca02bec062d1492#89cf104a2416144d1bbbae30aca02bec062d1492" [[package]] name = "zingolib" version = "5.0.0" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "append-only-vec", "bech32", @@ -5175,7 +5175,7 @@ dependencies = [ [[package]] name = "zingolib_testutils" version = "0.1.0" -source = "git+https://github.com/zingolabs/zingolib?tag=zingolib_nym_rc0#f65251c5648ebdb96f00d3f2a9de92b11e139abb" +source = "git+https://github.com/zingolabs/zingolib?branch=dev#eb62284f4149c58892db1abc0a3dc650169ccd32" dependencies = [ "http", "nonempty", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b52f9a5a0..65e5f3615 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -14,12 +14,12 @@ exclude = ["nym-proxy-ffi"] resolver = "2" [workspace.dependencies] -zingolib = { git = "https://github.com/zingolabs/zingolib", tag = "zingolib_nym_rc0", features = [ +zingolib = { git = "https://github.com/zingolabs/zingolib", branch = "dev", features = [ "nym", "perspective", ] } -pepper-sync = { git = "https://github.com/zingolabs/zingolib", tag = "zingolib_nym_rc0" } -zingolib_testutils = { git = "https://github.com/zingolabs/zingolib", tag = "zingolib_nym_rc0" } +pepper-sync = { git = "https://github.com/zingolabs/zingolib", branch = "dev" } +zingolib_testutils = { git = "https://github.com/zingolabs/zingolib", branch = "dev" } regchest_utils = { git = "https://github.com/zingolabs/zingo-regchest", branch = "dev" } zingomobile_utils = { path = "zingomobile_utils" } diff --git a/rust/android/Cargo.toml b/rust/android/Cargo.toml index b8af884a9..5ead91a6c 100644 --- a/rust/android/Cargo.toml +++ b/rust/android/Cargo.toml @@ -13,4 +13,4 @@ zingolib_testutils = { workspace = true } zingomobile_utils = { workspace = true } regchest_utils = { workspace = true } tokio = { workspace = true } -zcash_local_net = { git = "https://github.com/zingolabs/infrastructure.git", rev = "537f84d3d81b228c06ae82365f306ff364e164da" } +zcash_local_net = { git = "https://github.com/zingolabs/infrastructure.git", rev = "89cf104a2416144d1bbbae30aca02bec062d1492" } diff --git a/rust/android/docker/Dockerfile b/rust/android/docker/Dockerfile index ddf5a16d5..d511f5d51 100644 --- a/rust/android/docker/Dockerfile +++ b/rust/android/docker/Dockerfile @@ -109,7 +109,7 @@ ENV CC=aarch64-linux-android26-clang RUN cargo ndk --target aarch64-linux-android build --release --package zingo-nym-proxy-ffi RUN cargo run --release --manifest-path /opt/zingo/rust/Cargo.toml \ - --package zingo-uniffi-bindgen -- generate \ + --package zingo-uniffi-bindgen --bin zingo-uniffi-bindgen -- generate \ --library target/aarch64-linux-android/release/$SHIM \ --language kotlin --out-dir generated-kotlin RUN llvm-strip --strip-all target/aarch64-linux-android/release/$SHIM diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs index 3060e7090..c5ed32fb7 100644 --- a/rust/lib/src/lib.rs +++ b/rust/lib/src/lib.rs @@ -161,19 +161,17 @@ fn ffi_error(e: LightClientError) -> ZingolibError { LightClientError::FileError(_) => ZingolibError::Save(text), LightClientError::WalletError(_) => ZingolibError::Wallet(text), LightClientError::Offline => ZingolibError::Offline, - LightClientError::PriceError(_) | LightClientError::PriceFetchRequiresMixnet => { - ZingolibError::Read(text) - } + LightClientError::PriceError(_) => ZingolibError::Read(text), LightClientError::MixnetNotReady(_) | LightClientError::ProbeRequiresMixnet => { ZingolibError::Mixnet(text) } - // A deliberate verdict (#1229): exhausting the eligible Correspondents + // A deliberate verdict (#1229): exhausting the eligible Destinations // is a server-topology problem, not a mixnet refusal — switching the // synchronization endpoint changes eligibility, so the app's // switch-and-retry routing can genuinely help. Mapping it to Mixnet // would stamp it with the owned refusal marker and turn it // never-retry. - LightClientError::NoEligibleCorrespondent(_) + LightClientError::NoEligibleDestination(_) | LightClientError::IneligibleProbeTarget(_) | LightClientError::MigrationTransmissionTargetIsSyncEndpoint { .. } => { ZingolibError::Indexer(text) @@ -964,8 +962,8 @@ mod ffi_error_routing_tests { #[test] fn excluded_indexer_exhaustion_is_an_indexer_failure_not_a_refusal() { - let mapped = ffi_error(LightClientError::NoEligibleCorrespondent( - zingolib::correspondent::NoEligibleCorrespondents::EmptyPool, + let mapped = ffi_error(LightClientError::NoEligibleDestination( + zingolib::destination::NoEligibleDestinations::EmptyPool, )); assert!( matches!(&mapped, ZingolibError::Indexer(_)), @@ -2157,11 +2155,6 @@ pub fn get_total_spends_to_address() -> Result { } pub fn zec_price() -> Result { - // This wallet fetches price over the mixnet or not at all (ADR 0011). - // Every refusal the lightclient raises reaches the caller as one, the - // deliberate switch-off included: a price oracle learns the IP that asked - // it and when, which is a profile of when this wallet is awake, and no - // phone should hand that over as the cost of showing a number. let usd = with_initialized_lightclient_read(|lightclient| { RT.block_on(async move { lightclient