Skip to content
Open
179 changes: 152 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,142 @@ App Store: [https://apps.apple.com/app/zingo/id1668209531](https://apps.apple.co

Google Play: [https://play.google.com/store/apps/details?id=org.ZingoLabs.Zingo](https://play.google.com/store/apps/details?id=org.ZingoLabs.Zingo)

# Security Vulnerability Disclosure
## Security Vulnerability Disclosure

If you believe you have discovered a security issue, please contact us at:

zingodisclosure@proton.me

## Features

**Wallet**

- Light client: no full chain download, it syncs from a `lightwalletd` server through zingolib
- Follows the chain while it is open, so a payment shows up when it lands
- Background sync of the wallet while the app is closed: once a day on Android, around 3 a.m., on an
unmetered network while charging; when iOS grants a background processing task
- Create from a new seed, restore from a seed, or import a Unified Full Viewing Key (view-only)
- One wallet at a time, with the last one kept as a backup that can be restored from Settings
- Mainnet, Testnet and Regtest, fixed when the wallet is created; a server on another network is caught
on opening, with a switch to the right one
- Offline mode: create or restore a wallet with no server, and sync once one is chosen
- Seed backup flow (the words, a check that they are on paper, three words to confirm), with a reminder
in History until it is done; a restored wallet counts as backed up
- Seed phrase and viewing key screens, behind device authentication
- Performance profiles (Maximum, High, Medium, Low)
- Sync report with a scanning map, the last background sync, and Rescan
- A birthday below Sapling activation is raised to it, and the app says so once
- A wallet file that cannot be opened is classified (missing, encrypted…) and explained; Try Again
retries without changing any setting

**Transactions**

- Unified, Sapling, Transparent and TEX addresses; a TEX send goes out as two transactions through a
temporary transparent address
- Shielded by default: new addresses are Orchard (Ironwood) unless another type is chosen
- Encrypted memos, with a byte counter
- Shield the transparent balance from History or Pools
- Any amount can be typed in USD instead of ZEC (mainnet); what is sent is always ZEC
- Scan a QR into the recipient field
- `zcash:` payment links open the app (ZIP-321, first recipient)
- Send progress in steps (build, mixnet, mempool, confirmations), with what to do when one fails
- History with filters (received, sent, shielded, failed, memos, swaps), and Messages for transfers
carrying memos
- Receive: every address grouped by pool, with tags, pins, ownership check and new addresses by type
- Pools: the balance per pool (Ironwood, Orchard, Sapling, Transparent)
- Financial Insight: activity over time and amounts per recipient

**Servers**

- Automatic, from a list, custom (with a test that shows each step) or offline
- Live list from the community registry ([hosh.zec.rocks](https://hosh.zec.rocks)), ranked by 30-day
uptime and then ping, with the built-in list as the fallback
- Status dot beside the active server

**Privacy**

- Nym mixnet transport for sending a payment and for the ZEC price, through a native SOCKS5 proxy
handed to zingolib
- Sending fails closed: a payment goes out over the mixnet or not at all, unless the mixnet has been
switched off deliberately
- Mixnet Doctor: diagnoses the transport and the server through the tunnel, with a redacted report to copy
- Hide sensitive values from the header
- Screenshots and screen recording blocked on the seed and key screens (Android `FLAG_SECURE`, capture
detection on iOS); a copied seed or viewing key is cleared from the clipboard after 60 seconds

**Swaps**

- Swap ZEC to and from assets on other chains through SwapKit (NEAR Intents, Maya, THORChain and Flashnet)
- Routes compared against the market rate, with the slippage tolerance chosen
- Swap history, detail and tracking, with links to each chain's explorer and refund tracking
- When a provider offers no route, its own reason is given (a minimum to reach, a pair it does not
trade…); a provider that refuses to start a swap it has just quoted is set aside, with its reason
- Deposit QR and payment link for paying a purchase from another wallet; a token deposit that needs a
memo gets no QR, since no payment link can carry it
- Buying ZEC with a token (USDT, USDC…) from another wallet never goes through Maya or THORChain: their
memo travels only through the router contract, which no wallet's Send calls, and a plain token transfer
to their vault is neither seen nor refunded. Those routes are listed as unavailable, with the reason;
paying with a chain's own coin (BTC, ETH…) keeps them
- The address field reads a QR code and checks each chain's format
- A Maya deposit is paid in one shielded transaction, with its memo in a 0 ZEC note to Maya's memo
address, when Maya takes it; otherwise, and for THORChain, the memo rides in an OP_RETURN after a
deshield. A deposit whose second transaction did not go out is finished by Try Again
- A Maya purchase lands shielded when its memo can carry a unified address, and a Maya sale's refund
comes back shielded
- An expired purchase is still watched for a late payment
- The addresses made for swaps have their own group in Receive
- When the swap service is busy (it rate-limits the app's key), quotes pause with a countdown and resume
by themselves
- Swap records are kept per wallet and cleared with it
- Swap traffic goes over clearnet, not the mixnet; the ZEC deposit itself is a payment and goes through it

**Ironwood migration**

- Meet Ironwood, then a choice: migrate now or not; the migration sends the Orchard funds to Ironwood
in self-transactions that confirm together

**Address book**

- Contacts per network, searchable
- Contacts on other chains too, with Swap To / Swap From
- Scan a QR into a contact, or save one from Send, Swap or a transaction's detail
- Zcash Names: type `alice.zcash` or `alice.zec` in Send or a contact, and it resolves to the address

**Settings**

- Languages: English, Spanish, Portuguese, Russian and Turkish
- Block explorer for transactions and addresses (ZcashExplorer, ZecBlock, ZExplorer or none)
- Device authentication (biometrics or the device PIN) to open the app and to see the seed
- Support email with redacted diagnostics

## Building The App

Please see the platform specific [iOS](./docs/ios_developer_quickstart.md) and [Android](./docs/android_developer_quickstart.md) "quickstart" documentation.
Please see the platform-specific [iOS](./docs/ios_developer_quickstart.md) and [Android](./docs/android_developer_quickstart.md) quickstarts.

- Node is pinned in `.nvmrc` (`nvm install` reads it), and the package manager is Yarn 1.
- Swaps need a SwapKit API key. Set `SWAPKIT_API_KEY` before `yarn install`: its `postinstall` writes
`app/swap/swapKitSecrets.ts`, which is gitignored. Without the variable a placeholder is written, so
TypeScript and Jest still run, but every swap call fails.
- The first build fetches the Binding Layer (see below), which needs network access to `ghcr.io`.

## The Binding Layer

The Rust wallet and the mixnet proxy, with their Kotlin and Swift bindings,
live in [zingolabs/zingolib](https://github.com/zingolabs/zingolib), which
publishes them as Prebuilt Bundles. This repository pins one published
zingolib commit in `binding-layer.json`, the Bundle Pin (ADR
zingo-mobile/0020). The Android and iOS builds fetch the pinned bundles
through the `fetch:binding-layer` script. A build host needs Node and
network access to `ghcr.io` the first time. `yarn pin <commit>` moves the
pin to another published commit. The tests that read zingolib's source
run in zingolib.

## Releasing

Zingo ships as two parallel apps from this repo:

- **Production** (`org.ZingoLabs.Zingo`) — App Store + Play Production.
- **Beta** (`org.ZingoLabs.Zingo.Beta`) — TestFlight External + Play Open Testing.
- **Production** (`org.ZingoLabs.Zingo`): App Store and Play Production.
- **Beta** (`org.ZingoLabs.Zingo.Beta`): TestFlight External and Play Open Testing.

Both share the same JS bundle and Rust libs; only the native shell differs
(bundle ID, display name, icon with `BETA` band). Version/build bumps are
Expand Down Expand Up @@ -53,31 +173,33 @@ records sit under `docs/adr/zingo-mobile/` once the submodule is initialised.
The [zingo-adrs README](https://github.com/zingolabs/zingo-adrs#pointing-a-code-repository-at-zingo-adrs) explains how to read them, advance the
pointer, and propose a record.

## The Binding Layer

The Rust wallet and the mixnet proxy, with their Kotlin and Swift bindings,
live in [zingolabs/zingolib](https://github.com/zingolabs/zingolib), which
publishes them as Prebuilt Bundles. This repository pins one published
zingolib commit in `binding-layer.json`, the Bundle Pin (ADR
zingo-mobile/0020). The Android and iOS builds fetch the pinned bundles
through the `fetch:binding-layer` script. A build host needs Node and
network access to `ghcr.io` the first time. `yarn pin <commit>` moves the
pin to another published commit. The tests that read zingolib's source
run in zingolib.
[CONTEXT.md](./CONTEXT.md) is the glossary of the terms this repository uses,
and [docs/agents/](./docs/agents/) holds the notes for coding agents (domain,
security practices, wallet file states, issue triage).

## Testing
### Yarn Tests
1. From the root directory, run: <br />
`yarn test`

### JavaScript checks

These run on every pull request:

- `yarn test`: the Jest suite
- `yarn typecheck`: TypeScript
- `yarn lint:check`: ESLint
- `yarn prettier:check`: Prettier over every JS/TS file in the repository, so one unformatted file fails it
- `yarn check:compiler:changed --base=origin/dev`: the React Compiler ratchet; a changed file may not
add bailouts over its version in the base

### Memory Benchmark

Peak heap per wallet-file path, on a connected Android device or emulator.

- `yarn bench:memory`: measure and compare against `scripts/wallet_memory_baseline.json`, non-zero exit on a regression
- `yarn bench:memory --report`: measure only
- `yarn bench:memory:accept`: record a new baseline

### Device Tests

The device tests exercise the Binding Layer through Kotlin and Swift on an
emulator or a simulator. An annotation on each Android test class names its
kind (`CONTEXT.md` defines the terms).
Expand Down Expand Up @@ -110,12 +232,12 @@ To run the iOS tests, open `ios/Zingo.xcworkspace` in Xcode and run the
`ZingoTests` target on a simulator.

### End-to-End Tests (Maestro UI flows)

[Maestro](https://maestro.mobile.dev/) drives the released app from the
outside, asserting on the rendered UI. Flows live in `.maestro/` as YAML
(`01_new_wallet.yaml`, etc.). Runs nightly in CI via
(`01_new_wallet.yaml` to `09_screen_awake.yaml`). They run nightly in CI via
`.github/workflows/maestro-nightly.yaml` against both Android and iOS;
this is the e2e suite that PR reviewers and releases lean on going
forward.
this is the e2e suite that PR reviewers and releases lean on.

To run locally:

Expand All @@ -136,15 +258,18 @@ access. The `screen-awake` flow runs alone in CI, after a step that
shortens the device's screen-off timeout. New e2e coverage lands as
Maestro flows.

# Storybook & visual review
## Storybook & visual review

Browse components in isolation with Storybook (on-device via
`yarn storybook:ios`/`storybook:android`, web via `yarn storybook:web`).

The web build powers per-PR visual regression: `yarn visual` captures and
diffs components and animations against a baseline. See
[visual/README.md](./visual/README.md) for the harness, and
[.github/CLOUDFLARE.md](./.github/CLOUDFLARE.md) for the Cloudflare Pages deploy
that publishes the review site.
diffs components and animations against a baseline. On a pull request whose
captures changed, a comment of `/visual-accept` by someone with write access
records the new baseline. See [visual/README.md](./visual/README.md) for the
harness, and [.github/CLOUDFLARE.md](./.github/CLOUDFLARE.md) for the
Cloudflare Pages deploy that publishes the review site.

## Troubleshooting

# Troubleshooting
For notes on known issues and problems, see the [trouble-shooting notes](./TROUBLESHOOTING.md).
73 changes: 73 additions & 0 deletions __tests__/DepositSlip.tokenMemo.unit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
jest.mock('@app/RPCModule', () => ({ __esModule: true, default: {} }));

import 'react-native';
import React from 'react';
import { render, screen } from '@testing-library/react-native';
import {
ContextAppLoadedProvider,
defaultAppContextLoaded,
} from '@app/context';
import { SwapDirectionEnum } from '@app/swap/enums/SwapDirectionEnum';
import { SwapKitProviderEnum } from '@app/swap/enums/SwapKitProviderEnum';
import { SwapStatusEnum } from '@app/swap/enums/SwapStatusEnum';
import type { SwapRecordType } from '@app/swap/types/SwapRecordType';
import DepositSlip from '@screens/Swap/components/DepositSlip';

const usdtPurchase = (memo: string | undefined) =>
({
recordId: 'rec-1',
depositAddress: '0xd84379da1bcf7db871313cab1d93dd23ab3a076f',
destinationAddress: 't1wallet',
provider: SwapKitProviderEnum.MayachainStreaming,
direction: SwapDirectionEnum.Inbound,
routeId: 'route-1',
sellAsset: {
swapKitId: 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7',
chain: 'ETH',
symbol: 'USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7',
ticker: 'USDT',
chainId: '1',
decimals: 6,
},
receiveAsset: {
swapKitId: 'ZEC.ZEC',
chain: 'ZEC',
symbol: 'ZEC',
ticker: 'ZEC',
chainId: 'zcash',
decimals: 8,
},
sellAmountHumanDecimal: '65.076',
expectedReceiveAmount: '0.06',
minReceiveAmount: '0.059',
status: SwapStatusEnum.AwaitingExternalDeposit,
providerData: memo ? { memo } : undefined,
fiatValueBasis: { sellUsdUnitPrice: 0, receiveUsdUnitPrice: 0 },
createdAtMs: 1_700_000_000_000,
updatedAtMs: 1_700_000_000_000,
}) as unknown as SwapRecordType;

const renderSlip = (record: SwapRecordType) =>
render(
<ContextAppLoadedProvider
value={{ ...defaultAppContextLoaded, translate: (k: string) => k }}
>
<DepositSlip record={record} />
</ContextAppLoadedProvider>,
);

describe('DepositSlip for a token', () => {
test('Tests that one banner asks for a memo wallet and the network when the token deposit has a memo', () => {
renderSlip(usdtPurchase('=:ZEC.ZEC:t1wallet'));
expect(screen.getByTestId('swap.deposit.network-memo')).toBeOnTheScreen();
expect(screen.getByText('swap.network-memo-only')).toBeOnTheScreen();
expect(screen.queryByTestId('swap.deposit.no-qr')).toBeNull();
expect(screen.queryByText('swap.network-only')).toBeNull();
});

test('Tests that only the network banner shows when the token deposit has no memo', () => {
renderSlip(usdtPurchase(undefined));
expect(screen.getByText('swap.network-only')).toBeOnTheScreen();
expect(screen.queryByTestId('swap.deposit.network-memo')).toBeNull();
});
});
56 changes: 56 additions & 0 deletions __tests__/History.empty.unit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* @format
*/

import 'react-native';
import React from 'react';

import { act, fireEvent, render } from '@testing-library/react-native';
import { useNavigation } from '@react-navigation/native';
import History from '@screens/History';
import {
defaultAppContextLoaded,
ContextAppLoadedProvider,
} from '@app/context';
import { Route } from '@app/AppState';
import { mockInfo } from '../__mocks__/dataMocks/mockInfo';
import { mockTotalBalance } from '../__mocks__/dataMocks/mockTotalBalance';
import { mockTranslate } from '../__mocks__/dataMocks/mockTranslate';
import { mockAddresses } from '../__mocks__/dataMocks/mockAddresses';
import { HomeTabScreenProps } from '@app/types';
import mockNavigation from '../__mocks__/dataMocks/mockNavigation';

const props: HomeTabScreenProps<'History'> = {
navigation: mockNavigation,
route: { key: 'Key-1', name: Route.History, params: undefined },
};

describe('History when the wallet has no activity', () => {
beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue(mockNavigation);
});

test('Tests that the sprout and Receive ZEC show, with no filters, when the wallet has no transfers', async () => {
const state = {
...defaultAppContextLoaded,
valueTransfers: [],
addresses: mockAddresses,
translate: mockTranslate,
info: mockInfo,
totalBalance: mockTotalBalance,
};
const screen = render(
<ContextAppLoadedProvider value={state}>
<History {...props} />
</ContextAppLoadedProvider>,
);
await act(async () => {});
expect(screen.getByTestId('history.empty')).toBeTruthy();
expect(screen.queryByTestId('history.filters')).toBeNull();
expect(screen.queryByTestId('history.filter.bar')).toBeNull();

fireEvent.press(screen.getByTestId('history.empty.receive'));

expect(mockNavigation.navigate).toHaveBeenCalledWith(Route.Receive);
});
});
29 changes: 29 additions & 0 deletions __tests__/swap.useSwapForm.unit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,32 @@ test('Tests that a sale asked about again fills the coin received, the amount an
expect(result.current.amount).toBe('0.01');
expect(result.current.activeAddress).toBe(BTC_ADDRESS);
});

test('Tests that the chosen coin stays when a flip loads the other direction’s catalog', async () => {
const USDT: TokenEntryType = {
chain: 'ETH',
chainId: '1',
ticker: 'USDT',
identifier: 'ETH.USDT-0xdAC17F958D2ee523a2206206994597C13D831ec7',
symbol: 'USDT',
name: 'Tether',
decimals: 6,
};
const { swapService } = service();
(
swapService.listRoutableTokens as jest.MockedFunction<
SwapService['listRoutableTokens']
>
).mockImplementation(async () => [BTC, USDT]);
const { result } = render(swapService);
await waitFor(() => expect(result.current.selectedToken).toEqual(BTC));
act(() => result.current.setSelectedToken(USDT));

act(() => result.current.flip());

await waitFor(() => expect(result.current.isOutbound).toBe(false));
await waitFor(() =>
expect(swapService.listRoutableTokens).toHaveBeenCalledTimes(2),
);
expect(result.current.selectedToken).toEqual(USDT);
});
Loading
Loading