diff --git a/__tests__/walletBackend.mixnetCoordinator.unit.test.ts b/__tests__/walletBackend.mixnetCoordinator.unit.test.ts index 03785fa73..f8fd872c9 100644 --- a/__tests__/walletBackend.mixnetCoordinator.unit.test.ts +++ b/__tests__/walletBackend.mixnetCoordinator.unit.test.ts @@ -30,16 +30,33 @@ async function flushPromises(): Promise { describe('deriveMixnetView', () => { const noDetail = null; - it('blocks sending in every state except off and ready', () => { + it('blocks sending in every state except switched_off and ready', () => { const blocked = (view: MixnetView) => view.sendBlocked; expect( blocked( deriveMixnetView( - { kind: 'status', mode: RPCMixnetModeEnum.off, socks5Addr: null }, + { + kind: 'status', + mode: RPCMixnetModeEnum.switchedOff, + socks5Addr: null, + }, noDetail, ), ), ).toBe(false); + // The unattached ground state carries no consent: absence blocks. + expect( + blocked( + deriveMixnetView( + { + kind: 'status', + mode: RPCMixnetModeEnum.unattached, + socks5Addr: null, + }, + noDetail, + ), + ), + ).toBe(true); expect( blocked( deriveMixnetView( @@ -194,7 +211,7 @@ describe('MixnetCoordinator', () => { coordinator.stop(); }); - it('keeps sends blocked when the first poll after a transport failure reports off (#1226)', async () => { + it('keeps sends blocked when the first poll after a transport failure reports unattached (#1226)', async () => { const startTransport = jest .fn() .mockRejectedValue(new Error('shim missing')); @@ -205,10 +222,11 @@ describe('MixnetCoordinator', () => { await coordinator.ensureForConnectedSession(); await flushPromises(); - // The wallet was never attached, so its default mode is `off` — the - // same string a deliberate disable produces. The poll must not read - // it as consent. - mockedBridge.mixnetModeInfo.mockResolvedValue(statusPayload('off')); + // The wallet was never attached, so it reports the `unattached` + // ground state — the five-state wallet distinguishes it from the + // deliberate `switched_off`, so no app-side consent bit is needed: + // absence blocks by itself. + mockedBridge.mixnetModeInfo.mockResolvedValue(statusPayload('unattached')); jest.advanceTimersByTime(STEADY_POLL_MILLIS); await flushPromises(); @@ -230,7 +248,7 @@ describe('MixnetCoordinator', () => { releaseNarration = resolve; }), ); - mockedBridge.disableMixnet.mockResolvedValue(statusPayload('off')); + mockedBridge.disableMixnet.mockResolvedValue(statusPayload('switched_off')); const startTransport = jest.fn().mockResolvedValue('127.0.0.1:1080'); const published: MixnetView[] = []; const coordinator = new MixnetCoordinator(startTransport, view => @@ -244,14 +262,14 @@ describe('MixnetCoordinator', () => { await flushPromises(); const latest = published[published.length - 1]; - expect(latest.statusKey).toBe('mixnet.status.off'); + expect(latest.statusKey).toBe('mixnet.status.switched-off'); expect(latest.sendBlocked).toBe(false); coordinator.stop(); }); - it('a poll reporting off after a deliberate disable keeps clearnet consent (#1226)', async () => { + it('a poll reporting switched_off after a deliberate disable keeps clearnet consent (#1226)', async () => { mockedBridge.attachMixnet.mockResolvedValue(statusPayload('ready', '127.0.0.1:1080')); - mockedBridge.disableMixnet.mockResolvedValue(statusPayload('off')); + mockedBridge.disableMixnet.mockResolvedValue(statusPayload('switched_off')); const startTransport = jest.fn().mockResolvedValue('127.0.0.1:1080'); const published: MixnetView[] = []; const coordinator = new MixnetCoordinator(startTransport, view => @@ -261,18 +279,21 @@ describe('MixnetCoordinator', () => { await coordinator.disable(); await flushPromises(); - mockedBridge.mixnetModeInfo.mockResolvedValue(statusPayload('off')); + // The wallet records the consent itself: a later poll keeps + // reporting `switched_off`, and the view stays consented with no + // app-side vetting. + mockedBridge.mixnetModeInfo.mockResolvedValue(statusPayload('switched_off')); jest.advanceTimersByTime(STEADY_POLL_MILLIS); await flushPromises(); const latest = published[published.length - 1]; - expect(latest.statusKey).toBe('mixnet.status.off'); + expect(latest.statusKey).toBe('mixnet.status.switched-off'); expect(latest.sendBlocked).toBe(false); coordinator.stop(); }); - it('disable publishes the deliberate off, with sending unblocked as consent', async () => { - mockedBridge.disableMixnet.mockResolvedValue(statusPayload('off')); + it('disable publishes the deliberate switched_off, with sending unblocked as consent', async () => { + mockedBridge.disableMixnet.mockResolvedValue(statusPayload('switched_off')); const published: MixnetView[] = []; const coordinator = new MixnetCoordinator( jest.fn().mockResolvedValue('127.0.0.1:1080'), @@ -283,7 +304,7 @@ describe('MixnetCoordinator', () => { await flushPromises(); expect(published).toHaveLength(1); - expect(published[0].statusKey).toBe('mixnet.status.off'); + expect(published[0].statusKey).toBe('mixnet.status.switched-off'); expect(published[0].sendBlocked).toBe(false); coordinator.stop(); }); diff --git a/__tests__/walletBackend.mixnetTransform.unit.test.ts b/__tests__/walletBackend.mixnetTransform.unit.test.ts index 291fb9f17..38b2bfc29 100644 --- a/__tests__/walletBackend.mixnetTransform.unit.test.ts +++ b/__tests__/walletBackend.mixnetTransform.unit.test.ts @@ -29,8 +29,11 @@ describe('describeRejection', () => { }); describe('parseMixnetMode', () => { - it('accepts each of the four modes exactly', () => { - expect(parseMixnetMode('off')).toBe(RPCMixnetModeEnum.off); + it('accepts each of the five modes exactly', () => { + expect(parseMixnetMode('unattached')).toBe(RPCMixnetModeEnum.unattached); + expect(parseMixnetMode('switched_off')).toBe( + RPCMixnetModeEnum.switchedOff, + ); expect(parseMixnetMode('bootstrapping')).toBe( RPCMixnetModeEnum.bootstrapping, ); @@ -46,6 +49,13 @@ describe('parseMixnetMode', () => { expect(parseMixnetMode(3)).toBeNull(); expect(parseMixnetMode({ mode: 'ready' })).toBeNull(); }); + + it('rejects the retired off token: it conflated consent with absence', () => { + // The five-state decomposition split `off` into `unattached` and + // `switched_off`; a parser that still accepted it would quietly + // reunify them (the zingolib mint pins the same rejection). + expect(parseMixnetMode('off')).toBeNull(); + }); }); describe('transformMixnetStatus', () => { @@ -63,7 +73,8 @@ describe('transformMixnetStatus', () => { it('reports every non-ready mode with a null address', () => { const nonReadyModes: readonly RPCMixnetModeEnum[] = [ - RPCMixnetModeEnum.off, + RPCMixnetModeEnum.unattached, + RPCMixnetModeEnum.switchedOff, RPCMixnetModeEnum.bootstrapping, RPCMixnetModeEnum.died, ]; diff --git a/app/LoadedApp/LoadedApp.tsx b/app/LoadedApp/LoadedApp.tsx index bde96c3e7..4b46eb192 100644 --- a/app/LoadedApp/LoadedApp.tsx +++ b/app/LoadedApp/LoadedApp.tsx @@ -125,6 +125,7 @@ import { RPCUfvkType } from '../walletBackend/types/RPCUfvkType'; import { INITIAL_MIXNET_VIEW, MixnetView, + PLATFORM_UNAVAILABLE_MIXNET_VIEW, } from '../walletBackend/transforms/mixnetPresenter'; import { startMixnetTransport } from '../walletBackend/utils/nymTransport'; import { RPCPerformanceLevelEnum } from '../walletBackend/enums/RPCPerformanceLevelEnum'; @@ -837,13 +838,15 @@ export class LoadedAppClass extends Component< blockExplorer: props.blockExplorer, nym: props.nym, - // 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. + // Mixnet Mode: fail-closed everywhere (#1235). Android gets the + // bootstrapping view its coordinator will replace; a platform whose + // transport has not landed yet (iOS until the framework attach) + // gets the blocked platform-unavailable view. Neither leaves the + // send gate open. mixnetView: Platform.OS === GlobalConst.platformOSandroid ? INITIAL_MIXNET_VIEW - : null, + : PLATFORM_UNAVAILABLE_MIXNET_VIEW, disableMixnet: this.disableMixnet, reenableMixnet: this.reenableMixnet, diff --git a/app/translations/en.json b/app/translations/en.json index 9cdc892cf..d86897025 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -451,7 +451,8 @@ }, "mixnet": { "status": { - "off": "Mixnet off (clearnet)", + "unattached": "Mixnet not connected", + "switched-off": "Mixnet off (clearnet)", "bootstrapping": "Connecting to mixnet…", "ready": "Mixnet ready", "died": "Mixnet connection lost", diff --git a/app/translations/es.json b/app/translations/es.json index edf565706..40860100a 100644 --- a/app/translations/es.json +++ b/app/translations/es.json @@ -451,7 +451,8 @@ }, "mixnet": { "status": { - "off": "Mixnet apagada (red abierta)", + "unattached": "Mixnet no conectada", + "switched-off": "Mixnet apagada (red abierta)", "bootstrapping": "Conectando a la mixnet…", "ready": "Mixnet lista", "died": "Conexión mixnet perdida", diff --git a/app/translations/pt.json b/app/translations/pt.json index d02eba1b9..4a2f9f171 100644 --- a/app/translations/pt.json +++ b/app/translations/pt.json @@ -451,7 +451,8 @@ }, "mixnet": { "status": { - "off": "Mixnet desligada (rede aberta)", + "unattached": "Mixnet não conectada", + "switched-off": "Mixnet desligada (rede aberta)", "bootstrapping": "Conectando à mixnet…", "ready": "Mixnet pronta", "died": "Conexão mixnet perdida", diff --git a/app/translations/ru.json b/app/translations/ru.json index 758f51032..85d25a7dc 100644 --- a/app/translations/ru.json +++ b/app/translations/ru.json @@ -451,7 +451,8 @@ }, "mixnet": { "status": { - "off": "Микснет выключен (открытая сеть)", + "unattached": "Микснет не подключён", + "switched-off": "Микснет выключен (открытая сеть)", "bootstrapping": "Подключение к микснету…", "ready": "Микснет готов", "died": "Соединение с микснетом потеряно", diff --git a/app/translations/tr.json b/app/translations/tr.json index d66c1c072..1fbe4ee0d 100644 --- a/app/translations/tr.json +++ b/app/translations/tr.json @@ -451,7 +451,8 @@ }, "mixnet": { "status": { - "off": "Mixnet kapalı (açık ağ)", + "unattached": "Mixnet bağlı değil", + "switched-off": "Mixnet kapalı (açık ağ)", "bootstrapping": "Mixnet'e bağlanılıyor…", "ready": "Mixnet hazır", "died": "Mixnet bağlantısı koptu", diff --git a/app/walletBackend/enums/RPCMixnetModeEnum.ts b/app/walletBackend/enums/RPCMixnetModeEnum.ts index 6c16f5726..8ccbb208f 100644 --- a/app/walletBackend/enums/RPCMixnetModeEnum.ts +++ b/app/walletBackend/enums/RPCMixnetModeEnum.ts @@ -1,12 +1,20 @@ /** - * The Mixnet Mode reported by zingolib: `off` is the user's deliberate - * per-session clearnet consent, `bootstrapping` is enabled-but-not-yet- - * reachable, `ready` carries the send and price surfaces over the mixnet, - * and `died` is an unconsented proxy loss — sends refuse until the user - * re-enables the mixnet. + * The five-state Mixnet Mode, rendered from zingolib's wire mint (ADR + * 0024): `unattached` is the ground state, no transport and no consent, so + * sends refuse. `switched_off` is the user's deliberate per-session + * clearnet consent, the one state that opens the send gate off-mixnet. + * `bootstrapping` is enabled but not yet reachable. `ready` carries the + * send and price surfaces over the mixnet. `died` is an unconsented proxy + * loss, sends refuse until the user re-enables the mixnet. + * + * The token values are zingolib's, verbatim. This enum re-declares them + * only until the typed UniFFI surface lands (zingo-mobile#1236). The + * retired token `off` is deliberately absent: it conflated absence with + * consent, and the parser rejects it. */ export enum RPCMixnetModeEnum { - off = 'off', + unattached = 'unattached', + switchedOff = 'switched_off', bootstrapping = 'bootstrapping', ready = 'ready', died = 'died', diff --git a/app/walletBackend/modules/MixnetCoordinator.ts b/app/walletBackend/modules/MixnetCoordinator.ts index a31146670..003f6bf52 100644 --- a/app/walletBackend/modules/MixnetCoordinator.ts +++ b/app/walletBackend/modules/MixnetCoordinator.ts @@ -20,10 +20,8 @@ */ import { RPCMixnetModeEnum } from '../enums/RPCMixnetModeEnum'; import { - ClearnetConsent, MixnetStatusReport, describeRejection, - vetPolledStatus, } from '../transforms/mixnetTransform'; import { MixnetView, @@ -64,10 +62,6 @@ export class MixnetCoordinator { private pollTimerID?: ReturnType; private pollLock: boolean = false; private lastStatus: MixnetStatusReport | null = null; - // The consent bit belongs to the coordinator, not the wallet: the wallet - // reports `off` both for a deliberate disable and for a never-attached - // session, and only the former is consent (#1226). - private consent: ClearnetConsent = 'none'; constructor( startTransport: StartMixnetTransport, @@ -85,7 +79,6 @@ export class MixnetCoordinator { * re-enable — never a silent fall-through to clearnet. */ async ensureForConnectedSession(): Promise { - this.consent = 'none'; try { const socks5Addr = await this.startTransport(); this.publish(await attachMixnet(socks5Addr)); @@ -95,9 +88,13 @@ export class MixnetCoordinator { this.schedulePolling(); } - /** The user's deliberate per-session consent to clearnet. */ + /** + * The user's deliberate per-session consent to clearnet. The wallet + * itself records it as `switched_off`, distinct from the unattached + * ground state, so no app-side consent bit exists anymore (the #1226 + * defense retired in favor of the wallet's five-state backstop). + */ async disable(): Promise { - this.consent = 'disabledThisSession'; this.publish(await disableMixnet()); } @@ -120,7 +117,7 @@ export class MixnetCoordinator { } this.pollLock = true; try { - this.publish(vetPolledStatus(await getMixnetStatus(), this.consent)); + this.publish(await getMixnetStatus()); } finally { this.pollLock = false; } diff --git a/app/walletBackend/transforms/mixnetPresenter.ts b/app/walletBackend/transforms/mixnetPresenter.ts index 35cb839b0..a8f020269 100644 --- a/app/walletBackend/transforms/mixnetPresenter.ts +++ b/app/walletBackend/transforms/mixnetPresenter.ts @@ -16,7 +16,7 @@ export type MixnetRecoveryAction = 'none' | 'wait' | 'reenable'; * translation key (`mixnet.status.*`), never display English; `narration` * is the live bootstrap line when one exists; `sendBlocked` is the * fail-closed verdict a send screen must respect — `true` in every state - * except an explicit `off` (deliberate clearnet consent) or `ready`. + * except `switched_off` (deliberate clearnet consent) or `ready`. */ export type MixnetView = { readonly statusKey: string; @@ -39,6 +39,21 @@ export const INITIAL_MIXNET_VIEW: MixnetView = { recovery: 'wait', }; +/** + * The view for a platform whose mixnet transport has not landed yet (iOS + * until the framework attach ships). Fail-closed (zingo-mobile#1235): a + * platform with no mixnet and no recorded clearnet consent must block + * sends, exactly as an unknowable transport does. Recovery is `none` + * because re-enable cannot start a transport the platform does not have. + */ +export const PLATFORM_UNAVAILABLE_MIXNET_VIEW: MixnetView = { + statusKey: 'mixnet.status.unknown', + socks5Addr: null, + narration: null, + sendBlocked: true, + recovery: 'none', +}; + /** * Derives the screen-facing view from the typed reports. * @@ -68,9 +83,20 @@ export function deriveMixnetView( } switch (status.mode) { - case RPCMixnetModeEnum.off: + case RPCMixnetModeEnum.unattached: + // The ground state: no transport and no consent. The wallet itself + // distinguishes this from the deliberate switch-off now, so absence + // never opens the send gate (the #1226 conflation, retired). + return { + statusKey: 'mixnet.status.unattached', + socks5Addr: null, + narration: null, + sendBlocked: true, + recovery: 'reenable', + }; + case RPCMixnetModeEnum.switchedOff: return { - statusKey: 'mixnet.status.off', + statusKey: 'mixnet.status.switched-off', socks5Addr: null, narration: null, sendBlocked: false, diff --git a/app/walletBackend/transforms/mixnetTransform.ts b/app/walletBackend/transforms/mixnetTransform.ts index 1d14d2a59..1b64362dd 100644 --- a/app/walletBackend/transforms/mixnetTransform.ts +++ b/app/walletBackend/transforms/mixnetTransform.ts @@ -14,8 +14,7 @@ import { export type MixnetFailure = | { readonly reason: 'nativeRejection'; readonly message: string } | { readonly reason: 'malformedPayload'; readonly payload: string } - | { readonly reason: 'unrecognizedMode'; readonly claimed: string } - | { readonly reason: 'unconsentedOff' }; + | { readonly reason: 'unrecognizedMode'; readonly claimed: string }; /** * The validated outcome of a mixnet status call. A discriminated union so @@ -38,38 +37,6 @@ export type MixnetDetailReport = | { readonly kind: 'detail'; readonly detail: string } | { readonly kind: 'failure'; readonly failure: MixnetFailure }; -/** - * Whether this session holds the user's deliberate clearnet consent. - * `off` from the wallet is trustworthy only under `disabledThisSession`: - * a never-attached wallet (and a silently recreated one) also reports - * `off`, and that must not open the send gate (zingo-mobile#1226). - */ -export type ClearnetConsent = 'none' | 'disabledThisSession'; - -/** - * Vets a polled status against the session's consent. A polled `off` - * without consent is re-typed as the policy failure it actually is, so the - * derived view keeps sends blocked and offers re-enable instead of - * silently opening clearnet. Every other report passes through untouched; - * direct reports (an attach result, a disable result) are authoritative - * and are not vetted. - * - * Pure function — no side effects. - */ -export function vetPolledStatus( - status: MixnetStatusReport, - consent: ClearnetConsent, -): MixnetStatusReport { - if ( - status.kind === 'status' && - status.mode === RPCMixnetModeEnum.off && - consent === 'none' - ) { - return { kind: 'failure', failure: { reason: 'unconsentedOff' } }; - } - return status; -} - /** * Converts a value thrown by the native bridge — the error channel — into * the typed failure. Never inspects the data channel. @@ -86,13 +53,17 @@ export function describeRejection(thrown: unknown): MixnetFailure { * Validates an untrusted value as a Mixnet Mode. * * Pure function — no side effects. Returns `null` for anything that is not - * exactly one of the four mode strings, so an unknown future mode degrades - * to an explicit failure instead of a misread state. + * exactly one of the five mode strings, so an unknown future mode degrades + * to an explicit failure instead of a misread state. The retired token + * `off` is rejected on purpose: accepting it would reunify consent with + * absence, the conflation the five states exist to prevent. */ export function parseMixnetMode(candidate: unknown): RPCMixnetModeEnum | null { switch (candidate) { - case RPCMixnetModeEnum.off: - return RPCMixnetModeEnum.off; + case RPCMixnetModeEnum.unattached: + return RPCMixnetModeEnum.unattached; + case RPCMixnetModeEnum.switchedOff: + return RPCMixnetModeEnum.switchedOff; case RPCMixnetModeEnum.bootstrapping: return RPCMixnetModeEnum.bootstrapping; case RPCMixnetModeEnum.ready: diff --git a/components/Send/Send.tsx b/components/Send/Send.tsx index a5dcd1a08..282294f89 100644 --- a/components/Send/Send.tsx +++ b/components/Send/Send.tsx @@ -870,11 +870,13 @@ const Send: React.FunctionComponent = ({ !( !memoEnabled && Utils.parseStringLocaleToNumberFloat(amountText) === 0 ) && - // Mixnet Mode fail-closed verdict: while the transport is - // bootstrapping, died, or unknowable, sending stays blocked; only - // `ready` or the user's explicit clearnet consent (`off`) opens it. - // Null means the platform runs no mixnet policy yet (iOS). - (mixnetView === null || !mixnetView.sendBlocked), + // Mixnet Mode fail-closed verdict (#1235): only `ready` or the + // user's recorded clearnet consent (`switched_off`) opens the + // gate. A null view is unknowable and blocks, exactly as + // bootstrapping, died, and platform-unavailable do; absence is + // never consent. + mixnetView !== null && + !mixnetView.sendBlocked, ); }, [ memoEnabled, diff --git a/components/Settings/Settings.tsx b/components/Settings/Settings.tsx index 383d1458a..81d0b4f73 100644 --- a/components/Settings/Settings.tsx +++ b/components/Settings/Settings.tsx @@ -1744,8 +1744,11 @@ const Settings: React.FunctionComponent = ({ { - if (mixnetView.sendBlocked === false && - mixnetView.statusKey === 'mixnet.status.off') { + if ( + mixnetView.statusKey === 'mixnet.status.switched-off' + ) { + // Leaving the consented clearnet: revoke by + // re-enabling the mixnet. reenableMixnet(); } else if ( mixnetView.statusKey === 'mixnet.status.ready' || @@ -1753,12 +1756,13 @@ const Settings: React.FunctionComponent = ({ ) { disableMixnet(); } else { - // died / unknown: the only way up is a fresh start. + // unattached / died / unknown: the only way up is + // a fresh start. reenableMixnet(); } }} > - {mixnetView.statusKey === 'mixnet.status.off' ? ( + {mixnetView.statusKey === 'mixnet.status.switched-off' ? ( ) : ( diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 8d840ca2f..261751db9 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2279,7 +2279,7 @@ dependencies = [ [[package]] name = "pepper-sync" version = "0.5.0" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "bip32", "byteorder", @@ -2309,7 +2309,7 @@ dependencies = [ "zcash_protocol 0.10.0", "zcash_transparent 0.9.0", "zingo-memo", - "zingo-netutils 5.0.1 (git+https://github.com/zingolabs/zingolib?branch=dev)", + "zingo-netutils", "zingo-status", "zip32", ] @@ -5108,7 +5108,6 @@ dependencies = [ "zcash_client_backend", "zcash_keys", "zcash_protocol 0.10.0", - "zingo-netutils 5.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "zingo_common_components", "zingolib", "zip32", @@ -5122,7 +5121,7 @@ source = "git+https://github.com/zingolabs/infrastructure.git?rev=537f84d3d81b22 [[package]] name = "zingo-memo" version = "0.1.1" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "zcash_address 0.13.0 (registry+https://github.com/rust-lang/crates.io-index)", "zcash_encoding", @@ -5131,23 +5130,14 @@ dependencies = [ ] [[package]] -name = "zingo-netutils" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "338411422fb22781f131d934b29cd82313189b5c9a6f3bbd035b958464716b46" -dependencies = [ - "http", - "lightwallet-protocol", - "thiserror 1.0.69", - "tokio-rustls", - "tokio-stream", - "tonic", -] +name = "zingo-net-diag" +version = "0.1.0" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" [[package]] name = "zingo-netutils" version = "5.0.1" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "http", "hyper-util", @@ -5165,9 +5155,10 @@ dependencies = [ [[package]] name = "zingo-price" version = "0.1.0" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "byteorder", + "futures", "reqwest", "rust_decimal", "rustls 0.23.42", @@ -5175,12 +5166,13 @@ dependencies = [ "serde_json", "thiserror 2.0.19", "zcash_encoding", + "zingo-net-diag", ] [[package]] name = "zingo-status" version = "0.2.1" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "byteorder", "zcash_protocol 0.10.0", @@ -5203,7 +5195,7 @@ source = "git+https://github.com/zingolabs/infrastructure.git?rev=537f84d3d81b22 [[package]] name = "zingolib" version = "5.0.0" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "append-only-vec", "bech32", @@ -5251,7 +5243,8 @@ dependencies = [ "zcash_protocol 0.10.0", "zcash_transparent 0.9.0", "zingo-memo", - "zingo-netutils 5.0.1 (git+https://github.com/zingolabs/zingolib?branch=dev)", + "zingo-net-diag", + "zingo-netutils", "zingo-price", "zingo-status", "zingo_common_components", @@ -5262,7 +5255,7 @@ dependencies = [ [[package]] name = "zingolib_testutils" version = "0.1.0" -source = "git+https://github.com/zingolabs/zingolib?branch=dev#3d2f2aa0c9d38df9eb35952a24494fb28493409c" +source = "git+https://github.com/zingolabs/zingolib?rev=53e806f4733cd63f3dac4ddbe9b868b9a298f0de#53e806f4733cd63f3dac4ddbe9b868b9a298f0de" dependencies = [ "http", "nonempty", @@ -5274,7 +5267,7 @@ dependencies = [ "zcash_local_net", "zcash_primitives 0.29.0", "zcash_protocol 0.10.0", - "zingo-netutils 5.0.1 (git+https://github.com/zingolabs/zingolib?branch=dev)", + "zingo-netutils", "zingo_common_components", "zingo_test_vectors", "zingolib", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index bf94185fc..996ee67c5 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,16 +3,20 @@ members = ["lib", "android", "ios", "zingomobile_utils", "workbench"] resolver = "2" [workspace.dependencies] -# TEMPORARY pin (send-over-nym step 4, zingolib#2505): the mixnet attach seam -# was built against zingolib's `nym_mobile_adoption` branch, since deleted. -# Pinned by rev to that branch's head so the arc still resolves and builds; -# later stacked PRs advance the pin along zingolib's own history. The `nym` -# feature enables the Mixnet Mode surface. -zingolib = { git = "https://github.com/zingolabs/zingolib", branch = "dev", features = [ +# Pinned by rev, never branch (ADR 0024 decision 7). The rev is the +# convergence stack's phase1-dep-funnel head, carrying the five-state +# Mixnet Mode wire mint, the session driver, the mixnet-only price rule, +# and the netutils funnel. Later PRs advance the pin along zingolib's own +# history. The `nym` feature enables the Mixnet Mode surface. +zingolib = { git = "https://github.com/zingolabs/zingolib", rev = "53e806f4733cd63f3dac4ddbe9b868b9a298f0de", features = [ + "nym", +] } +pepper-sync = { git = "https://github.com/zingolabs/zingolib", rev = "53e806f4733cd63f3dac4ddbe9b868b9a298f0de" } +zingolib_testutils = { git = "https://github.com/zingolabs/zingolib", rev = "53e806f4733cd63f3dac4ddbe9b868b9a298f0de", features = [ + # The scenarios record clearnet consent before their funding sends, + # which the five-state wallet demands of any sender (ADR 0011). "nym", ] } -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" } @@ -30,7 +34,10 @@ zcash_keys = { version = "0.15.0", features = [ zcash_protocol = "0.10.0" zingo_common_components = "0.4.0" -zingo-netutils = { version = "5.0.1", features = ["globally-public-transparent"] } +# No direct zingo-netutils dependency: the app funnels through zingolib's +# re-exports (zingolib::netutils, ADR 0024 decision 7). This retires the +# crates.io duplicate copy that compiled without the SOCKS5 capability +# (zingo-mobile#1236, zingolib#2566). zip32 = "0.2.0" uniffi = "0.29" tokio = "1" diff --git a/rust/lib/Cargo.toml b/rust/lib/Cargo.toml index c4e4a72da..09e1b94a4 100644 --- a/rust/lib/Cargo.toml +++ b/rust/lib/Cargo.toml @@ -13,7 +13,6 @@ zcash_address = { workspace = true } zcash_protocol = { workspace = true } zcash_client_backend = { workspace = true } zingo_common_components = { workspace = true } -zingo-netutils = { workspace = true } http = { workspace = true } json = { workspace = true } diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs index bf3c27d00..ae13b2948 100644 --- a/rust/lib/src/lib.rs +++ b/rust/lib/src/lib.rs @@ -39,7 +39,7 @@ use tokio::runtime::Runtime; use zcash_address::ZcashAddress; use zcash_protocol::memo::MemoBytes; use zcash_protocol::value::Zatoshis; -use zingo_netutils::{GrpcIndexer, Indexer}; +use zingolib::netutils::{GrpcIndexer, Indexer}; use zingolib::config::{ ChainType, ClientConfig, DEFAULT_INDEXER_URI, DEFAULT_INDEXER_URI_TESTNET, WalletConfig, construct_indexer_uri, lib_birthday, @@ -2008,11 +2008,18 @@ pub fn get_total_spends_to_address() -> Result { pub fn zec_price() -> Result { with_initialized_lightclient(|lightclient| { RT.block_on(async move { - let price = lightclient + // Mixnet-only price (ADR 0011, amendment 2026-07-28): the fetch + // succeeds only in Ready and returns the tunnel endpoint it + // traveled through as per-fetch route evidence. + let fetch = lightclient .update_current_price() .await .map_err(ffi_error)?; - Ok(object! { "current_price" => price }.pretty(2)) + Ok(object! { + "current_price" => fetch.usd, + "via_socks5" => fetch.via_socks5, + } + .pretty(2)) }) }) } @@ -3175,16 +3182,6 @@ pub fn cancel_ironwood_migration() -> Result { }) } -/// The Mixnet Mode tri-state-plus-died as the strings the app layer shows. -fn mixnet_mode_string(mode: zingolib::nym::MixnetMode) -> &'static str { - match mode { - zingolib::nym::MixnetMode::Off => "off", - zingolib::nym::MixnetMode::Bootstrapping => "bootstrapping", - zingolib::nym::MixnetMode::Ready => "ready", - zingolib::nym::MixnetMode::Died => "died", - } -} - /// Attach Mixnet Mode to an already-running, platform-hosted SOCKS5 endpoint /// (the UniFFI proxy shim's address). Readiness is validated by a data round /// trip; poll [`mixnet_mode`] for `bootstrapping` -> `ready`, or `died`. @@ -3200,7 +3197,7 @@ pub fn attach_mixnet(socks5_addr: String) -> Result { .await .map_err(|e| ZingolibError::Mixnet(e.to_string()))?; Ok( - object! { "mixnet_mode" => mixnet_mode_string(lightclient.mixnet_mode()) } + object! { "mixnet_mode" => lightclient.mixnet_mode().as_str() } .pretty(2), ) }) @@ -3225,7 +3222,7 @@ pub fn enable_mixnet(proxy_path: String) -> Result { .await .map_err(|e| ZingolibError::Mixnet(e.to_string()))?; Ok( - object! { "mixnet_mode" => mixnet_mode_string(lightclient.mixnet_mode()) } + object! { "mixnet_mode" => lightclient.mixnet_mode().as_str() } .pretty(2), ) }) @@ -3245,7 +3242,10 @@ pub fn disable_mixnet() -> Result { if let Some(lightclient) = &mut *guard { Ok(RT.block_on(async move { lightclient.disable_mixnet().await; - object! { "mixnet_mode" => "off" }.pretty(2) + // The mint's token for the deliberate disable is + // "switched_off"; rendering the actual mode keeps this + // truthful if the semantics ever move again. + object! { "mixnet_mode" => lightclient.mixnet_mode().as_str() }.pretty(2) })) } else { Err(ZingolibError::LightclientNotInitialized) @@ -3263,7 +3263,7 @@ pub fn mixnet_mode() -> Result { .map_err(|_| ZingolibError::LightclientLockPoisoned)?; if let Some(lightclient) = &*guard { let mut status = object! { - "mixnet_mode" => mixnet_mode_string(lightclient.mixnet_mode()), + "mixnet_mode" => lightclient.mixnet_mode().as_str(), }; if let Some(addr) = lightclient.mixnet_socks5_addr() { status["socks5_addr"] = addr.into();