Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 37 additions & 16 deletions __tests__/walletBackend.mixnetCoordinator.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,33 @@ async function flushPromises(): Promise<void> {
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(
Expand Down Expand Up @@ -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'));
Expand All @@ -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();

Expand All @@ -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 =>
Expand All @@ -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 =>
Expand All @@ -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'),
Expand All @@ -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();
});
Expand Down
17 changes: 14 additions & 3 deletions __tests__/walletBackend.mixnetTransform.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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', () => {
Expand All @@ -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,
];
Expand Down
11 changes: 7 additions & 4 deletions app/LoadedApp/LoadedApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,

Expand Down
3 changes: 2 additions & 1 deletion app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion app/translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion app/translations/pt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion app/translations/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@
},
"mixnet": {
"status": {
"off": "Микснет выключен (открытая сеть)",
"unattached": "Микснет не подключён",
"switched-off": "Микснет выключен (открытая сеть)",
"bootstrapping": "Подключение к микснету…",
"ready": "Микснет готов",
"died": "Соединение с микснетом потеряно",
Expand Down
3 changes: 2 additions & 1 deletion app/translations/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 14 additions & 6 deletions app/walletBackend/enums/RPCMixnetModeEnum.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down
17 changes: 7 additions & 10 deletions app/walletBackend/modules/MixnetCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@
*/
import { RPCMixnetModeEnum } from '../enums/RPCMixnetModeEnum';
import {
ClearnetConsent,
MixnetStatusReport,
describeRejection,
vetPolledStatus,
} from '../transforms/mixnetTransform';
import {
MixnetView,
Expand Down Expand Up @@ -64,10 +62,6 @@ export class MixnetCoordinator {
private pollTimerID?: ReturnType<typeof setInterval>;
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,
Expand All @@ -85,7 +79,6 @@ export class MixnetCoordinator {
* re-enable — never a silent fall-through to clearnet.
*/
async ensureForConnectedSession(): Promise<void> {
this.consent = 'none';
try {
const socks5Addr = await this.startTransport();
this.publish(await attachMixnet(socks5Addr));
Expand All @@ -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<void> {
this.consent = 'disabledThisSession';
this.publish(await disableMixnet());
}

Expand All @@ -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;
}
Expand Down
32 changes: 29 additions & 3 deletions app/walletBackend/transforms/mixnetPresenter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
*
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading