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
2 changes: 1 addition & 1 deletion App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const basicTheme: ThemeType = {

const Stack = createStackNavigator<AppStackParamList>();

export const navigationRef = createNavigationContainerRef();
const navigationRef = createNavigationContainerRef();

const App: React.FunctionComponent = () => {
const [theme, setTheme] = useState<ThemeType>(advancedTheme);
Expand Down
25 changes: 25 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,31 @@ and completion-time vocabulary on "residual")
The single user approval of an exact plan hash before anything is signed or
sent. Covers the whole migration, both phases.

## Mixnet Mode

**Mixnet Mode**:
Routing the send (transaction broadcast) and price-fetch surfaces over the
Nym mixnet. Synchronization is never covered; the IP-correlation disclaimer
(ZIP-0318) states that boundary. Modes: `off`, `bootstrapping`, `ready`,
`died`.

**Fail-closed**:
The policy that when Mixnet Mode is anything but `off`, a covered surface
that cannot reach the mixnet refuses rather than falling back to clearnet.
A refusal is not a server error and is never retried.

**Silent alpha APK**:
An alpha build of the app that routes the covered surfaces over Nym with
the stock (pre-Mixnet-Mode) UX/UI — no toggle, no banners, no disclaimer
screen. Its purpose is isolating transport behavior from UI work.
_Avoid_: silent mode (it is a build, not a runtime mode)

**Always On** (build flavor):
The build flavors that produce the silent alpha APKs: Mixnet Mode is enabled
unconditionally at wallet initialization and cannot be disabled at runtime.
Two network variants exist — `alwayson` first-runs on mainnet, and
`alwaysontest` first-runs on testnet — installable side by side.

## CI

**Blocking check** — a PR CI job whose failure fails the pull request.
Expand Down
56 changes: 56 additions & 0 deletions __tests__/CheckAddressVerdict.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @format
*/

import { interpretCheckAddressResult } from '../components/Receive/components/checkAddressVerdict';
import { FfiResult } from '../app/walletBackend/ffi';

const ok = (value: string): FfiResult<string> => ({ ok: true, value });
const rejected = (): FfiResult<string> => ({
ok: false,
error: { code: 'InvalidInput', message: 'bad address' },
});

describe('interpretCheckAddressResult', () => {
test('is_wallet_address true is a positive verdict', () => {
const raw = JSON.stringify({ is_wallet_address: true, account_id: 0 });
expect(interpretCheckAddressResult(ok(raw))).toEqual({ kind: 'mine' });
});

test('is_wallet_address false is a negative verdict', () => {
const raw = JSON.stringify({ is_wallet_address: false, account_id: 0 });
expect(interpretCheckAddressResult(ok(raw))).toEqual({ kind: 'notMine' });
});

test('a typed FFI rejection is named, and carries its code', () => {
expect(interpretCheckAddressResult(rejected())).toEqual({
kind: 'ffiRejection',
code: 'InvalidInput',
message: 'bad address',
});
});

// EVIDENCE of the misinterpretation this replaces: the screen stored
// `is_wallet_address` straight off JSON.parse behind a `verifyOK !== null`
// render gate, so a well-formed payload lacking the field stored
// `undefined`, passed the gate, and rendered the definitive "this address
// does not belong to you" — a confident false negative produced by a
// check that never returned a verdict.
test('a payload without is_wallet_address is malformed, not "not your address"', () => {
const raw = JSON.stringify({ encoded_address: 'u1aaa' });
expect(interpretCheckAddressResult(ok(raw)).kind).toBe('malformed');
});

// EVIDENCE, same gate: a truthy non-boolean must not read as "yours".
test('a non-boolean is_wallet_address is malformed, not a verdict', () => {
const raw = JSON.stringify({ is_wallet_address: 'yes' });
expect(interpretCheckAddressResult(ok(raw)).kind).toBe('malformed');
});

// EVIDENCE: a parse failure used to be swallowed by a bare catch, so the
// user tapped Verify and nothing happened at all.
test('an unparseable or empty payload is malformed, never silent', () => {
expect(interpretCheckAddressResult(ok('not json')).kind).toBe('malformed');
expect(interpretCheckAddressResult(ok('')).kind).toBe('malformed');
});
});
56 changes: 56 additions & 0 deletions __tests__/ListSelection.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @format
*/

import { deriveListSelection } from '../app/utils/listSelection';

type Addr = { address: string };
const addrs = (...names: string[]): Addr[] =>
names.map(address => ({ address }));

describe('deriveListSelection', () => {
test('a null index designates nothing', () => {
expect(deriveListSelection(addrs(), null)).toEqual({
kind: 'noSelection',
});
expect(deriveListSelection(addrs('u1aaa'), null)).toEqual({
kind: 'noSelection',
});
});

// EVIDENCE of misinterpretation at Receive.tsx (doCopy and the NAT/EA
// sheets): the populating effect encodes an *empty* filtered list as
// index 0, and the read sites treated `index !== null` as proof an
// address exists — so `tAddr[0].address` threw on an empty list. The
// correct pattern (null check plus length check) already existed in
// `currentAddress`; this function is its total, shared form.
test('an empty list stored as index 0 is empty, not a selected item', () => {
expect(deriveListSelection(addrs(), 0)).toEqual({ kind: 'empty' });
});

test('a valid index selects that item', () => {
expect(deriveListSelection(addrs('u1aaa', 'u1bbb', 'u1ccc'), 2)).toEqual({
kind: 'selected',
item: { address: 'u1ccc' },
index: 2,
});
});

// AddressBook stores -1 for "Add-new mode, no real item" alongside null
// for "sheet closed" — two sentinels for the same non-state. A negative
// index must never designate an item.
test('a negative sentinel designates nothing', () => {
expect(deriveListSelection(addrs('u1aaa', 'u1bbb'), -1)).toEqual({
kind: 'noSelection',
});
});

// A stale index can outlive a list refresh. Deliberately NOT clamped:
// presenting a different item than the user chose (an edit sheet opening
// on the wrong contact) is worse than designating none.
test('a stale index beyond the list designates nothing', () => {
expect(deriveListSelection(addrs('u1aaa'), 5)).toEqual({
kind: 'noSelection',
});
});
});
140 changes: 140 additions & 0 deletions __tests__/SendFieldUpdates.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* @format
*/

import {
applySendFieldUpdates,
SendFields,
} from '../components/Send/sendFieldUpdates';

const PRICE_USD = 35;

const fields = (overrides: Partial<SendFields> = {}): SendFields => ({
address: 'u1existingaddress',
amount: '',
amountCurrency: '',
memo: '',
includeUAMemo: false,
...overrides,
});

describe('writing the ZEC amount', () => {
// REGRESSION EVIDENCE: updateToField used to take five positional slots,
// with the ZEC and fiat amount slots adjacent and identically typed
// `string | null`. Expressing "the user typed 2 ZEC" one slot to the
// right ran the coupling backwards — the form silently held
// 2 / 35 ≈ 0.057 ZEC (captured: expected '2', received '0.05714286'),
// and the type system could not object. Under SendFieldUpdate the write
// names its field, so that transposition is inexpressible: this same
// scenario now passes by construction.
test('sets amount to the typed text and computes the fiat counterpart', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'amount', value: '2' }],
PRICE_USD,
);
expect(next.amount).toBe('2');
expect(next.amountCurrency).toBe('70.00');
});

test('writing the fiat amount computes the ZEC amount from the price', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'amountCurrency', value: '70' }],
PRICE_USD,
);
expect(next.amountCurrency).toBe('70');
expect(next.amount).toBe('2.00000000');
});

test('an unknown price clears the counterpart instead of inventing one', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'amount', value: '2' }],
0,
);
expect(next.amount).toBe('2');
expect(next.amountCurrency).toBe('');
});

test('a non-numeric amount clears the counterpart', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'amount', value: 'not-a-number' }],
PRICE_USD,
);
expect(next.amount).toBe('not-a-number');
expect(next.amountCurrency).toBe('');
});

test('the two amounts are one value: clearing either clears both', () => {
const cleared = applySendFieldUpdates(
fields({ amount: '1.5', amountCurrency: '52.50' }),
[{ field: 'amount', value: '' }],
PRICE_USD,
);
expect(cleared.amount).toBe('');
expect(cleared.amountCurrency).toBe('');

const clearedViaFiat = applySendFieldUpdates(
fields({ amount: '1.5', amountCurrency: '52.50' }),
[{ field: 'amountCurrency', value: '' }],
PRICE_USD,
);
expect(clearedViaFiat.amount).toBe('');
expect(clearedViaFiat.amountCurrency).toBe('');
});

test('amounts truncate to their field widths', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'amount', value: '1'.repeat(30) }],
0,
);
expect(next.amount).toHaveLength(20);
});
});

describe('independent fields', () => {
test('a plain address is stripped of whitespace', () => {
const next = applySendFieldUpdates(
fields(),
[{ field: 'address', value: ' u1a bc\n' }],
PRICE_USD,
);
expect(next.address).toBe('u1abc');
});

test('memo and includeUAMemo write without touching the amounts', () => {
const next = applySendFieldUpdates(
fields({ amount: '1.5', amountCurrency: '52.50' }),
[
{ field: 'memo', value: 'hola' },
{ field: 'includeUAMemo', value: true },
],
PRICE_USD,
);
expect(next.memo).toBe('hola');
expect(next.includeUAMemo).toBe(true);
expect(next.amount).toBe('1.5');
expect(next.amountCurrency).toBe('52.50');
});

test('a batch applies in order (the memo auto-seed pair)', () => {
const next = applySendFieldUpdates(
fields(),
[
{ field: 'amount', value: '0' },
{ field: 'memo', value: 'auto-seeded' },
],
PRICE_USD,
);
expect(next.amount).toBe('0');
expect(next.memo).toBe('auto-seeded');
});

test('an empty batch changes nothing', () => {
const prev = fields({ amount: '1.5', amountCurrency: '52.50' });
expect(applySendFieldUpdates(prev, [], PRICE_USD)).toEqual(prev);
});
});
51 changes: 51 additions & 0 deletions __tests__/ServerProbeVerdict.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* @format
*/

import { serverProbeVerdict } from '../app/serverProbeVerdict';
import { ServerUrisType } from '../app/AppState';

const probe = (latency: number | null): ServerUrisType =>
({
uri: 'https://zec.rocks:443',
region: 'na',
chainName: 'main',
default: true,
latency,
obsolete: false,
}) as ServerUrisType;

describe('serverProbeVerdict', () => {
test('a null probe result (all candidates failed or timed out) is unreachable', () => {
expect(serverProbeVerdict(null)).toEqual({ kind: 'unreachable' });
});

test('an unmeasured probe (latency null) is unreachable', () => {
expect(serverProbeVerdict(probe(null))).toEqual({ kind: 'unreachable' });
});

test('a measured probe is reachable and carries the measurement', () => {
const s = probe(57);
expect(serverProbeVerdict(s)).toEqual({
kind: 'reachable',
server: s,
latencyMs: 57,
});
});

// EVIDENCE of misinterpretation at LoadingApp.tsx:1017, 1103, and 1391:
// production reads the resolved latency with truthiness
// (`serverChecked && serverChecked.latency`), so a 0 ms measurement —
// two Date.now() calls landing in the same millisecond, e.g. against a
// localhost regtest server — is read as the null "probe failed" state.
// selectingServer.ts:33 only resolves a server that actually answered,
// so any resolved probe, 0 ms included, is a reachable server.
test('a 0 ms round trip is a reachable server, not a dead one', () => {
const s = probe(0);
expect(serverProbeVerdict(s)).toEqual({
kind: 'reachable',
server: s,
latencyMs: 0,
});
});
});
Loading
Loading