From fcbc01185522be478c0cfabc74944bc0fabbc986 Mon Sep 17 00:00:00 2001 From: dorianvp Date: Mon, 31 Aug 2026 18:33:34 -0300 Subject: [PATCH 1/2] fix: derive clearnet price consent from Nym state, not the indicator A Nym-off wallet consented to clearnet only when the indicator read exactly `off`. A rejecting `disableMixnet()` settles the view on `unknown`, so consent went false and the cadence cleared with nothing to republish it, killing the price surface for the session. Treat a Nym-off wallet as consented in every state except one where the mixnet route actually serves (`ready`/`bootstrapping`). The Nym-on half is unchanged. Closes #1361 Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/priceStore.cadence.tsx | 15 +++++++++-- __tests__/priceStore.lifecycle.tsx | 7 ++--- __tests__/priceStore.trafficGuards.tsx | 31 +++++++++++++++++++++- components/Components/priceFetcherStore.ts | 10 ++++--- 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/__tests__/priceStore.cadence.tsx b/__tests__/priceStore.cadence.tsx index b6b369561..f2c40f065 100644 --- a/__tests__/priceStore.cadence.tsx +++ b/__tests__/priceStore.cadence.tsx @@ -23,6 +23,7 @@ import { import { SelectServerEnum } from '../app/AppState'; import { getZecPrice } from '../app/walletBackend'; import { mockInfo } from '../__mocks__/dataMocks/mockInfo'; +import { INITIAL_MIXNET_VIEW } from '../app/walletBackend/transforms/mixnetView'; const price = getZecPrice as jest.MockedFunction; @@ -92,11 +93,21 @@ test('turning Nym on mid-session fetches at once', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); - const view = render(driverUi(makeCtx({ nym: false }), setZecPrice)); + const view = render( + driverUi( + makeCtx({ nym: false, mixnetView: INITIAL_MIXNET_VIEW }), + setZecPrice, + ), + ); await jest.advanceTimersByTimeAsync(10_000); expect(price).not.toHaveBeenCalled(); - view.rerender(driverUi(makeCtx({ nym: true }), setZecPrice)); + view.rerender( + driverUi( + makeCtx({ nym: true, mixnetView: INITIAL_MIXNET_VIEW }), + setZecPrice, + ), + ); await jest.advanceTimersByTimeAsync(0); expect(price).toHaveBeenCalledTimes(1); }); diff --git a/__tests__/priceStore.lifecycle.tsx b/__tests__/priceStore.lifecycle.tsx index ccbbb8551..2ac8a0f07 100644 --- a/__tests__/priceStore.lifecycle.tsx +++ b/__tests__/priceStore.lifecycle.tsx @@ -467,14 +467,15 @@ test('rapid app hops inside the cooldown do not multiply fetches', async () => { expect(price).toHaveBeenCalledTimes(1); }); -test('without the Nym selection no price traffic exists', async () => { +test('Nym off while the route still serves stays off clearnet', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); seedDeps(setZecPrice); - // USD may even be the seeded default; the currency authorizes nothing. - render(fetcherUi(makeCtx({ nym: false }), setZecPrice)); + render( + fetcherUi(makeCtx({ nym: false, mixnetView: READY_VIEW }), setZecPrice), + ); await jest.advanceTimersByTimeAsync(61_000); fireAppState('background'); fireAppState('active'); diff --git a/__tests__/priceStore.trafficGuards.tsx b/__tests__/priceStore.trafficGuards.tsx index 1bc2b5aa5..379636d60 100644 --- a/__tests__/priceStore.trafficGuards.tsx +++ b/__tests__/priceStore.trafficGuards.tsx @@ -36,6 +36,15 @@ const DIED_VIEW: MixnetView = { reconnecting: false, }; +const UNKNOWN_VIEW: MixnetView = { + statusKey: 'mixnet.status.unknown', + socks5Addr: null, + narration: null, + sendBlocked: true, + recovery: 'reenable', + reconnecting: false, +}; + const price = getZecPrice as jest.MockedFunction; type Ctx = typeof defaultAppContextLoaded; @@ -144,7 +153,12 @@ test('a withdrawn opt-in stops the mid-flight retry', async () => { const view = render(surfaceUi(makeCtx(), setZecPrice)); await waitFor(() => expect(price).toHaveBeenCalledTimes(1)); - view.rerender(surfaceUi(makeCtx({ nym: false }), setZecPrice)); // Nym off + view.rerender( + surfaceUi( + makeCtx({ nym: false, mixnetView: INITIAL_MIXNET_VIEW }), + setZecPrice, + ), + ); land({ price: -1, error: 'refused' }); await flush(); @@ -204,6 +218,21 @@ test('a switch-off fetches the price over clearnet', async () => { ); }); +test('a switch-off whose disable rejected still fetches over clearnet', async () => { + price.mockResolvedValue({ price: 42, error: '' }); + const setZecPrice = jest.fn(); + + render( + surfaceUi( + makeCtx({ nym: false, mixnetView: UNKNOWN_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(); diff --git a/components/Components/priceFetcherStore.ts b/components/Components/priceFetcherStore.ts index 2025906b4..2fdd04b64 100644 --- a/components/Components/priceFetcherStore.ts +++ b/components/Components/priceFetcherStore.ts @@ -162,11 +162,13 @@ function surfaceMayFetch(): boolean { } function priceTrafficConsented(): boolean { + if (deps === undefined) return false; + if (deps.nymSelected) { + return deps.mixnetStatusKey !== 'mixnet.status.off'; + } return ( - deps !== undefined && - (deps.nymSelected - ? deps.mixnetStatusKey !== 'mixnet.status.off' - : deps.mixnetStatusKey === 'mixnet.status.off') + deps.mixnetStatusKey !== 'mixnet.status.ready' && + deps.mixnetStatusKey !== 'mixnet.status.bootstrapping' ); } From b38b3a218c7ddcb55825d4124bd78cfb1876f320 Mon Sep 17 00:00:00 2001 From: dorianvp Date: Mon, 31 Aug 2026 19:55:50 -0300 Subject: [PATCH 2/2] refactor: classify clearnet price fetch under an exhaustive switch --- __tests__/priceStore.lifecycle.tsx | 2 +- __tests__/priceStore.optIn.tsx | 48 ++++++++++++++++++++++ __tests__/priceStore.trafficGuards.tsx | 19 ++++++++- components/Components/priceFetcherStore.ts | 26 ++++++++---- 4 files changed, 84 insertions(+), 11 deletions(-) diff --git a/__tests__/priceStore.lifecycle.tsx b/__tests__/priceStore.lifecycle.tsx index 2ac8a0f07..e4cb1e281 100644 --- a/__tests__/priceStore.lifecycle.tsx +++ b/__tests__/priceStore.lifecycle.tsx @@ -467,7 +467,7 @@ test('rapid app hops inside the cooldown do not multiply fetches', async () => { expect(price).toHaveBeenCalledTimes(1); }); -test('Nym off while the route still serves stays off clearnet', async () => { +test('Nym off holds off clearnet while the route still serves', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); diff --git a/__tests__/priceStore.optIn.tsx b/__tests__/priceStore.optIn.tsx index 6ea893d54..b322200e7 100644 --- a/__tests__/priceStore.optIn.tsx +++ b/__tests__/priceStore.optIn.tsx @@ -24,9 +24,23 @@ import { import { CurrencyEnum, SelectServerEnum } from '../app/AppState'; import { getZecPrice } from '../app/walletBackend'; import { mockInfo } from '../__mocks__/dataMocks/mockInfo'; +import { + MIXNET_STATUS_KEYS, + MixnetStatusKey, + MixnetView, +} from '../app/walletBackend/transforms/mixnetView'; const price = getZecPrice as jest.MockedFunction; +const viewFor = (statusKey: MixnetStatusKey): MixnetView => ({ + statusKey, + socks5Addr: null, + narration: null, + sendBlocked: true, + recovery: 'none', + reconnecting: false, +}); + type Ctx = typeof defaultAppContextLoaded; const makeCtx = (over?: Partial): Ctx => ({ ...defaultAppContextLoaded, @@ -158,6 +172,40 @@ test('a return parked on a flight still arms the hop rate bound', async () => { expect(price).toHaveBeenCalledTimes(4); // rate-bound, no fifth call }); +const FETCH_EXPECTED: Record = { + 'true|mixnet.status.off': false, + 'true|mixnet.status.bootstrapping': true, + 'true|mixnet.status.ready': true, + 'true|mixnet.status.died': false, + 'true|mixnet.status.unknown': true, + 'false|mixnet.status.off': true, + 'false|mixnet.status.bootstrapping': false, + 'false|mixnet.status.ready': false, + 'false|mixnet.status.died': false, + 'false|mixnet.status.unknown': true, +}; + +test('the opt-in resolves a fetch for every mixnet status', async () => { + for (const nym of [true, false]) { + for (const statusKey of MIXNET_STATUS_KEYS) { + jest.useFakeTimers(); + price.mockReset(); + price.mockResolvedValue({ price: 42, error: '' }); + priceFetcherStore.resetForTests(); + const setZecPrice = jest.fn(); + + const view = render( + surfaceUi(makeCtx({ nym, mixnetView: viewFor(statusKey) }), setZecPrice), + ); + await jest.advanceTimersByTimeAsync(0); + + expect(price.mock.calls.length > 0).toBe(FETCH_EXPECTED[`${nym}|${statusKey}`]); + view.unmount(); + jest.useRealTimers(); + } + } +}); + test('a re-render behind the closed gate emits no traffic', async () => { jest.useFakeTimers(); price.mockResolvedValue({ price: 42, error: '' }); diff --git a/__tests__/priceStore.trafficGuards.tsx b/__tests__/priceStore.trafficGuards.tsx index 379636d60..20ff1638b 100644 --- a/__tests__/priceStore.trafficGuards.tsx +++ b/__tests__/priceStore.trafficGuards.tsx @@ -218,7 +218,7 @@ test('a switch-off fetches the price over clearnet', async () => { ); }); -test('a switch-off whose disable rejected still fetches over clearnet', async () => { +test('a failed disable still fetches the price over clearnet', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); @@ -233,6 +233,23 @@ test('a switch-off whose disable rejected still fetches over clearnet', async () ); }); +test('a disabled route that still refuses keeps retrying, not dying', async () => { + jest.useFakeTimers(); + price.mockResolvedValue({ price: -1, error: 'refused' }); + const setZecPrice = jest.fn(); + + render( + surfaceUi(makeCtx({ nym: false, mixnetView: UNKNOWN_VIEW }), setZecPrice), + ); + await jest.advanceTimersByTimeAsync(0); + const entryCalls = price.mock.calls.length; + expect(entryCalls).toBeGreaterThan(0); + + await jest.advanceTimersByTimeAsync(21 * 60_000); + expect(price.mock.calls.length).toBeGreaterThan(entryCalls); + expect(setZecPrice).not.toHaveBeenCalled(); +}); + test('opting in with the transport still off emits no clearnet fetch', async () => { price.mockResolvedValue({ price: 42, error: '' }); const setZecPrice = jest.fn(); diff --git a/components/Components/priceFetcherStore.ts b/components/Components/priceFetcherStore.ts index 2fdd04b64..75ac942dd 100644 --- a/components/Components/priceFetcherStore.ts +++ b/components/Components/priceFetcherStore.ts @@ -153,7 +153,7 @@ function transportRefuses(): boolean { function surfaceMayFetch(): boolean { return ( deps !== undefined && - priceTrafficConsented() && + priceTrafficAllowed() && deps.priceFetchable && attachCount > 0 && !appAway && @@ -161,15 +161,23 @@ function surfaceMayFetch(): boolean { ); } -function priceTrafficConsented(): boolean { - if (deps === undefined) return false; - if (deps.nymSelected) { - return deps.mixnetStatusKey !== 'mixnet.status.off'; +function clearnetFetchable(key: MixnetStatusKey): boolean { + switch (key) { + case 'mixnet.status.off': + case 'mixnet.status.died': + case 'mixnet.status.unknown': + return true; + case 'mixnet.status.ready': + case 'mixnet.status.bootstrapping': + return false; } - return ( - deps.mixnetStatusKey !== 'mixnet.status.ready' && - deps.mixnetStatusKey !== 'mixnet.status.bootstrapping' - ); +} + +function priceTrafficAllowed(): boolean { + if (deps === undefined) return false; + return deps.nymSelected + ? deps.mixnetStatusKey !== 'mixnet.status.off' + : clearnetFetchable(deps.mixnetStatusKey); } function scheduleAuto(): void {