diff --git a/README.md b/README.md index 1ae94b746b..d927e29ee3 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 @@ -53,24 +173,25 @@ 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 ` 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:
- `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 @@ -78,6 +199,7 @@ Peak heap per wallet-file path, on a connected Android device or emulator. - `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). @@ -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: @@ -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). diff --git a/__tests__/DepositSlip.tokenMemo.unit.tsx b/__tests__/DepositSlip.tokenMemo.unit.tsx new file mode 100644 index 0000000000..10f8635de8 --- /dev/null +++ b/__tests__/DepositSlip.tokenMemo.unit.tsx @@ -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( + k }} + > + + , + ); + +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(); + }); +}); diff --git a/__tests__/History.empty.unit.tsx b/__tests__/History.empty.unit.tsx new file mode 100644 index 0000000000..0c655cd63f --- /dev/null +++ b/__tests__/History.empty.unit.tsx @@ -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( + + + , + ); + 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); + }); +}); diff --git a/__tests__/swap.useSwapForm.unit.tsx b/__tests__/swap.useSwapForm.unit.tsx index d2a39f6776..d0b6ea2e31 100644 --- a/__tests__/swap.useSwapForm.unit.tsx +++ b/__tests__/swap.useSwapForm.unit.tsx @@ -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); +}); diff --git a/__tests__/swap/optimalRoute.test.ts b/__tests__/swap/optimalRoute.test.ts index 99c102f886..e3fa380471 100644 --- a/__tests__/swap/optimalRoute.test.ts +++ b/__tests__/swap/optimalRoute.test.ts @@ -1,6 +1,5 @@ import { optimalRouteId } from '@app/swap/optimalRoute'; import { RouteOptionType } from '@app/swap/types/RouteOptionType'; -import { SwapKitProviderEnum } from '@app/swap/enums/SwapKitProviderEnum'; const route = (routeId: string, tags?: string[]) => ({ routeId, tags }) as RouteOptionType; @@ -32,44 +31,4 @@ describe('optimalRouteId', () => { it('answers empty for no routes at all', () => { expect(optimalRouteId([])).toBe(''); }); - - describe('paying with a token', () => { - const priced = ( - routeId: string, - provider: SwapKitProviderEnum, - expectedReceiveAmount: string, - paidWithToken = true, - tags?: string[], - ) => - ({ - routeId, - provider, - expectedReceiveAmount, - paidWithToken, - tags, - }) as RouteOptionType; - const maya = (paidWithToken = true) => - priced( - 'maya', - SwapKitProviderEnum.MayachainStreaming, - '0.1043', - paidWithToken, - ['RECOMMENDED'], - ); - - it('Tests that the address-only route is selected when it is within 1% of the best', () => { - const near = priced('near', SwapKitProviderEnum.Near, '0.1036'); - expect(optimalRouteId([maya(), near])).toBe('near'); - }); - - it('Tests that the memo route stays selected when the others are more than 1% worse', () => { - const near = priced('near', SwapKitProviderEnum.Near, '0.1020'); - expect(optimalRouteId([maya(), near])).toBe('maya'); - }); - - it('Tests that nothing changes when the payment is not a token', () => { - const near = priced('near', SwapKitProviderEnum.Near, '0.1036', false); - expect(optimalRouteId([maya(false), near])).toBe('maya'); - }); - }); }); diff --git a/__tests__/swap/quoteRoutes.test.ts b/__tests__/swap/quoteRoutes.test.ts index 4d7475e6ec..c7189373c5 100644 --- a/__tests__/swap/quoteRoutes.test.ts +++ b/__tests__/swap/quoteRoutes.test.ts @@ -529,3 +529,62 @@ describe('providers that returned nothing', () => { }); }); }); + +describe('buying ZEC with a token', () => { + const USDT: SwapAssetType = { + swapKitId: 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7', + chain: 'ETH', + symbol: 'USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7', + ticker: 'USDT', + chainId: '1', + decimals: 6, + }; + const ETH: SwapAssetType = { + swapKitId: 'ETH.ETH', + chain: 'ETH', + symbol: 'ETH', + ticker: 'ETH', + chainId: '1', + decimals: 18, + }; + const quoteFrom = (sellAsset: SwapAssetType) => + serviceReturning({ + routes: [ + route({ routeId: 'maya' }), + route({ + routeId: 'thor', + providers: [SwapKitProviderEnum.ThorchainStreaming], + }), + route({ routeId: 'near', providers: [SwapKitProviderEnum.Near] }), + ], + }).service.quote({ + sellAsset, + receiveAsset: ZEC, + sellAmountHumanDecimal: '65', + sourceAddress: '0xsender', + destinationAddress: 't1ephemeral', + }); + + it('Tests that Maya and THORChain are set aside, with why, when the payment is a token', async () => { + const result = await quoteFrom(USDT); + expect(result.routes.map(r => r.routeId)).toEqual(['near']); + expect( + result.unavailable + .filter(row => + [ + SwapKitProviderEnum.MayachainStreaming, + SwapKitProviderEnum.ThorchainStreaming, + ].includes(row.provider), + ) + .map(row => english(row.reason)), + ).toEqual([ + 'Can’t be paid with a token from another wallet: it needs a memo the token transfer can’t carry.', + 'Can’t be paid with a token from another wallet: it needs a memo the token transfer can’t carry.', + ]); + }); + + it('Tests that Maya and THORChain stay when the payment is the chain’s own coin', async () => { + const result = await quoteFrom(ETH); + expect(result.routes.map(r => r.routeId)).toEqual(['maya', 'thor', 'near']); + }); +}); diff --git a/__tests__/swap/routeFeeValue.test.ts b/__tests__/swap/routeFeeValue.test.ts new file mode 100644 index 0000000000..b0e33ba33f --- /dev/null +++ b/__tests__/swap/routeFeeValue.test.ts @@ -0,0 +1,52 @@ +jest.mock('@app/RPCModule', () => + require('../../__mocks__/rpcModuleProxy').rpcModuleProxyMock(), +); + +import type { + FiatValueBasisType, + QuoteInput, + RouteOptionType, +} from '@app/swap'; +import { SwapKitProviderEnum } from '@app/swap/enums/SwapKitProviderEnum'; +import { routeFeeValue } from '@screens/Swap/routeFeeValue'; + +const route = (overrides: Partial = {}): RouteOptionType => ({ + routeId: 'route-near', + provider: SwapKitProviderEnum.Near, + expectedReceiveAmount: '0.98', + minReceiveAmount: '0.97', + ...overrides, +}); + +const quoteInput = { + sellAsset: { ticker: 'ZEC', symbol: 'ZEC' }, + receiveAsset: { ticker: 'USDT', symbol: 'USDT' }, + sellAmountHumanDecimal: '2', +} as unknown as QuoteInput; + +const prices = { + sellUsdUnitPrice: 50, + receiveUsdUnitPrice: 1, +} as FiatValueBasisType; + +test('Tests that the fee reads as a share of the sale with its dollars when it is in the sold asset', () => { + expect( + routeFeeValue({ + route: route({ totalFeesInSellAsset: '0.02' }), + quoteInput, + fiatValueBasis: prices, + isOutbound: true, + }), + ).toBe('1% · ≈ $1.00'); +}); + +test('Tests that the fee reads as the amount when no share can be told', () => { + expect( + routeFeeValue({ + route: route(), + quoteInput, + fiatValueBasis: prices, + isOutbound: true, + }), + ).toBe('—'); +}); diff --git a/app/swap/SwapService.ts b/app/swap/SwapService.ts index 6029e779a0..7edb218021 100644 --- a/app/swap/SwapService.ts +++ b/app/swap/SwapService.ts @@ -37,6 +37,7 @@ import { RouteOptionType } from './types/RouteOptionType'; import { SwapAssetType } from './types/SwapAssetType'; import { SwapRecordType } from './types/SwapRecordType'; import { TokenEntryType } from './types/TokensResponseType'; +import { isThorchainFamily } from './thorchainFamily'; import { unavailableProviders } from './unavailableProviders'; import type { UnavailableProviderType } from './unavailableProviders'; @@ -258,11 +259,19 @@ export class SwapService { ) .filter((route): route is RouteOptionType => route !== null); + // Paid with a token from another wallet, a Maya or THORChain route cannot + // be taken: their memo travels only through the router contract, which no + // wallet's Send calls, and a plain transfer to the vault is neither seen + // nor refunded (65 USDT were lost that way on 2026-11-10). + const tokenMemoRoutes = isTokenAsset(input.sellAsset) + ? projected.filter(route => isThorchainFamily(route.provider)) + : []; + const offered = projected.filter(route => !tokenMemoRoutes.includes(route)); // Split rather than filtered: a route through a provider with no executor // is a real offer this app cannot take, and dropping it without a trace is // indistinguishable on screen from the provider never having answered. - const routes = projected.filter(route => this.registry.has(route.provider)); - const unsupportedRoutes = projected.filter( + const routes = offered.filter(route => this.registry.has(route.provider)); + const unsupportedRoutes = offered.filter( route => !this.registry.has(route.provider), ); @@ -273,6 +282,7 @@ export class SwapService { routes, input, unsupportedRoutes, + tokenMemoRoutes, ), rawResponse: response, }; @@ -283,12 +293,14 @@ export class SwapService { routes: readonly RouteOptionType[], input: QuoteInput, unsupportedRoutes: readonly RouteOptionType[] = [], + tokenMemoRoutes: readonly RouteOptionType[] = [], ): UnavailableProviderType[] { return unavailableProviders({ response, supported: this.registry.supportedProviders(), routes, unsupportedRoutes, + tokenMemoRoutes, sellAssetTicker: input.sellAsset.ticker, }); } @@ -916,7 +928,6 @@ function toRouteOption( ? route.warnings.join('; ') : undefined, tags, - paidWithToken: isTokenAsset({ swapKitId: sellAssetId }), }; } diff --git a/app/swap/optimalRoute.ts b/app/swap/optimalRoute.ts index 57e805570d..7a1f2e1370 100644 --- a/app/swap/optimalRoute.ts +++ b/app/swap/optimalRoute.ts @@ -1,5 +1,4 @@ import { RouteOptionType } from './types/RouteOptionType'; -import { isThorchainFamily } from './thorchainFamily'; /** * The route to select when nobody has chosen one. @@ -12,34 +11,8 @@ import { isThorchainFamily } from './thorchainFamily'; */ export const OPTIMAL_ROUTE_TAG = 'RECOMMENDED'; -/** - * How much less, as a fraction of the best price, a route paid to an address - * alone may deliver and still be selected over one that needs a memo. - */ -export const ADDRESS_ONLY_MARGIN = 0.01; - -/** - * Paid with a token, which cannot carry a memo, a route that needs none is - * selected instead of a Maya or THORChain optimum when its price is within - * `ADDRESS_ONLY_MARGIN` of the best. The memo routes stay listed and stay - * "Best"; only the default changes. - */ export const optimalRouteId = ( routes: ReadonlyArray, -): string => { - const optimal = - routes.find(r => r.tags?.includes(OPTIMAL_ROUTE_TAG)) ?? routes[0]; - if ( - !optimal || - !optimal.paidWithToken || - !isThorchainFamily(optimal.provider) - ) { - return optimal?.routeId ?? ''; - } - const amount = (r: RouteOptionType) => parseFloat(r.expectedReceiveAmount); - const floor = Math.max(...routes.map(amount)) * (1 - ADDRESS_ONLY_MARGIN); - const close = routes - .filter(r => !isThorchainFamily(r.provider) && amount(r) >= floor) - .sort((a, b) => amount(b) - amount(a))[0]; - return (close ?? optimal).routeId; -}; +): string => + (routes.find(r => r.tags?.includes(OPTIMAL_ROUTE_TAG)) ?? routes[0]) + ?.routeId ?? ''; diff --git a/app/swap/types/RouteOptionType.ts b/app/swap/types/RouteOptionType.ts index 5ce53b678c..d46bd78312 100644 --- a/app/swap/types/RouteOptionType.ts +++ b/app/swap/types/RouteOptionType.ts @@ -68,7 +68,4 @@ export type RouteOptionType = { * `FASTEST`, `CHEAPEST`). The route picker maps `RECOMMENDED` to the * "Optimal" badge in the design. */ tags?: ReadonlyArray; - /** Paid with a token, whose transfer has no room for a memo: buying ZEC - * only, since ZEC itself is no token. */ - paidWithToken?: boolean; }; diff --git a/app/swap/unavailableProviders.ts b/app/swap/unavailableProviders.ts index ea87a63850..720d698b45 100644 --- a/app/swap/unavailableProviders.ts +++ b/app/swap/unavailableProviders.ts @@ -34,6 +34,7 @@ export function unavailableProviders({ supported, routes, unsupportedRoutes, + tokenMemoRoutes, sellAssetTicker, }: { response: QuoteResponseType; @@ -47,9 +48,14 @@ export function unavailableProviders({ * is indistinguishable from the provider never having answered. */ unsupportedRoutes?: readonly RouteOptionType[]; + /** Routes offered that need a memo a token payment cannot carry. */ + tokenMemoRoutes?: readonly RouteOptionType[]; sellAssetTicker?: string; }): UnavailableProviderType[] { const quoted = new Set(routes.map(route => route.provider)); + const needsMemo = new Set( + (tokenMemoRoutes ?? []).map(route => route.provider), + ); const errors = new Map(); for (const error of response.providerErrors ?? []) { // SwapKit names the provider in the same casing as the enum, but a @@ -62,10 +68,9 @@ export function unavailableProviders({ .filter(provider => !quoted.has(provider)) .map(provider => ({ provider, - reason: describeRefusal( - errors.get(provider.toUpperCase()), - sellAssetTicker, - ), + reason: needsMemo.has(provider) + ? swapKey('swap.msg-token-memo') + : describeRefusal(errors.get(provider.toUpperCase()), sellAssetTicker), })); // A route this app cannot take is a different fact from a provider that did diff --git a/app/theme/emptyHistoryArt.ts b/app/theme/emptyHistoryArt.ts new file mode 100644 index 0000000000..459cccb895 --- /dev/null +++ b/app/theme/emptyHistoryArt.ts @@ -0,0 +1,20 @@ +/** + * The colors of the empty history illustration (ui/widgets/EmptySprout), + * drawn by design: its gradients and shades are art, and name no role a token + * could. + */ +export const emptyHistoryArt = { + groundTop: '#141D1C', + groundBottom: '#031124', + moundStroke: '#314533', + stemOuter: '#0A4004', + stemDeep: '#0D540C', + stemLight: '#3CC52A', + leafLight: '#3CC52A', + leafMid: '#149D05', + leafDeep: '#0D540C', + vein: '#05234C', + fallenDeep: '#16700B', + fallenMid: '#149D05', + away: '#1B3C6B', +} as const; diff --git a/app/translations/en.json b/app/translations/en.json index 2ff7c6ae7e..82d10e79ac 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -854,11 +854,11 @@ "pay-address-memo": "Address + memo", "review-how-you-pay": "How you pay", "review-memo-ack": "My wallet can add {provider}’s memo.", - "qr-token-memo": "A {coin} send can’t carry this memo. Use a wallet that connects to {provider}.", "qr-carries-address": "The QR has only the address", "memo-hint-token": "Send it with the payment. Exchanges can’t.", "exact-amount-exchange": "From an exchange? Add its fee on top. If less than {amount} arrives, {provider} refunds it.", "network-only": "Send {coin} only on {network}. On another network the funds can be lost.", + "network-memo-only": "Send from a wallet that supports memos, and send {coin} only on {network}. On another network the funds can be lost.", "memo-hint-evm": "On this network the memo goes in the data field, not as a note.", "memo-hint-other": "The memo goes as the transaction memo.", "review-min": "You get at least", @@ -981,6 +981,7 @@ "msg-received-less": "Received {percent}% less than expected", "msg-received-more": "Received {percent}% more than expected", "msg-wallet-cannot": "This wallet cannot swap through this provider yet.", + "msg-token-memo": "Can’t be paid with a token from another wallet: it needs a memo the token transfer can’t carry.", "msg-refusal-rate-limited": "The provider is refusing requests for the moment. The next quote may reach it.", "msg-refusal-estimate": "Could not price this swap right now. It may be back on the next quote.", "msg-refusal-liquidity": "Not enough liquidity for this swap right now.", @@ -1130,6 +1131,9 @@ "filter-withfunds": "With funds", "filters": "Filters", "empty": "Nothing here yet.", + "empty-title": "No activity yet", + "empty-text": "Receive your first ZEC and your history will start to grow.", + "empty-receive": "Receive ZEC", "remove": "Delete", "remove-title": "Delete", "remove-error": "Error Removing :", diff --git a/app/translations/es.json b/app/translations/es.json index 0abe5152f6..f63926192d 100644 --- a/app/translations/es.json +++ b/app/translations/es.json @@ -854,11 +854,11 @@ "pay-address-memo": "Dirección + memo", "review-how-you-pay": "Cómo pagas", "review-memo-ack": "Mi monedero puede añadir el memo de {provider}.", - "qr-token-memo": "Un envío de {coin} no puede llevar este memo. Usa un monedero que se conecte a {provider}.", "qr-carries-address": "El QR solo lleva la dirección", "memo-hint-token": "Envíalo con el pago. Los exchanges no pueden.", "exact-amount-exchange": "¿Desde un exchange? Suma su comisión aparte. Si llega menos de {amount}, {provider} lo devuelve.", "network-only": "Envía {coin} solo por {network}. En otra red los fondos se pueden perder.", + "network-memo-only": "Envía desde una billetera que soporte memos y envía {coin} solo por {network}. En otra red los fondos se pueden perder.", "memo-hint-evm": "En esta red el memo va en el campo de datos, no como nota.", "memo-hint-other": "El memo va como memo de la transacción.", "review-min": "Recibes al menos", @@ -981,6 +981,7 @@ "msg-received-less": "Recibido un {percent}% menos de lo previsto", "msg-received-more": "Recibido un {percent}% más de lo previsto", "msg-wallet-cannot": "Este monedero todavía no puede hacer swaps con este proveedor.", + "msg-token-memo": "No se puede pagar con un token desde otra billetera: necesita un memo que la transferencia del token no puede llevar.", "msg-refusal-rate-limited": "El proveedor está rechazando peticiones por el momento. Puede que la próxima cotización llegue.", "msg-refusal-estimate": "No ha podido poner precio a este swap ahora mismo. Puede que vuelva en la próxima cotización.", "msg-refusal-liquidity": "No hay liquidez suficiente para este swap ahora mismo.", @@ -1130,6 +1131,9 @@ "filter-withfunds": "Con fondos", "filters": "Filtros", "empty": "Aún no hay nada aquí.", + "empty-title": "Aún no hay movimientos", + "empty-text": "Recibe tus primeros ZEC y tu historial empezará a crecer.", + "empty-receive": "Recibir ZEC", "remove": "Eliminar", "remove-title": "Eliminar", "remove-error": "Error Eliminando :", diff --git a/app/translations/pt.json b/app/translations/pt.json index a3ff1bde78..cd1f887bd6 100644 --- a/app/translations/pt.json +++ b/app/translations/pt.json @@ -854,11 +854,11 @@ "pay-address-memo": "Endereço + memo", "review-how-you-pay": "Como você paga", "review-memo-ack": "Minha carteira consegue adicionar o memo de {provider}.", - "qr-token-memo": "Um envio de {coin} não consegue levar este memo. Use uma carteira que se conecte com {provider}.", "qr-carries-address": "O QR tem só o endereço", "memo-hint-token": "Envie junto com o pagamento. As exchanges não conseguem.", "exact-amount-exchange": "De uma exchange? Some a taxa dela ao valor. Se chegar menos de {amount}, {provider} devolve.", "network-only": "Envie {coin} somente na {network}. Em outra rede os fundos podem se perder.", + "network-memo-only": "Envie de uma carteira que suporte memos e envie {coin} somente na {network}. Em outra rede os fundos podem se perder.", "memo-hint-evm": "Nesta rede o memo vai no campo de dados, não como nota.", "memo-hint-other": "O memo vai como memo da transação.", "review-min": "Você recebe no mínimo", @@ -981,6 +981,7 @@ "msg-received-less": "Recebido {percent}% a menos que o previsto", "msg-received-more": "Recebido {percent}% a mais que o previsto", "msg-wallet-cannot": "Esta carteira ainda não consegue fazer swaps com este provedor.", + "msg-token-memo": "Não pode ser pago com um token de outra carteira: precisa de um memo que a transferência do token não consegue levar.", "msg-refusal-rate-limited": "O provedor está recusando pedidos no momento. A próxima cotação pode chegar até ele.", "msg-refusal-estimate": "Não conseguiu cotar este swap agora. Pode voltar na próxima cotação.", "msg-refusal-liquidity": "Não há liquidez suficiente para este swap agora.", @@ -1130,6 +1131,9 @@ "filter-withfunds": "Com fundos", "filters": "Filtros", "empty": "Ainda não há nada aqui.", + "empty-title": "Ainda não há movimentos", + "empty-text": "Receba seus primeiros ZEC e seu histórico vai começar a crescer.", + "empty-receive": "Receber ZEC", "remove": "Excluir", "remove-title": "Excluir", "remove-error": "Error ao remover :", diff --git a/app/translations/ru.json b/app/translations/ru.json index d261bf4521..cbc5d5e1af 100644 --- a/app/translations/ru.json +++ b/app/translations/ru.json @@ -854,11 +854,11 @@ "pay-address-memo": "Адрес + мемо", "review-how-you-pay": "Как вы платите", "review-memo-ack": "Мой кошелёк может добавить мемо {provider}.", - "qr-token-memo": "Перевод {coin} не может передать это мемо. Используйте кошелёк, который подключается к {provider}.", "qr-carries-address": "В QR-коде только адрес", "memo-hint-token": "Отправьте его вместе с платежом. Биржи так не умеют.", "exact-amount-exchange": "С биржи? Добавьте её комиссию сверху. Если придёт меньше {amount}, {provider} вернёт средства.", "network-only": "Отправляйте {coin} только в сети {network}. В другой сети средства можно потерять.", + "network-memo-only": "Отправляйте из кошелька, который поддерживает мемо, и только {coin} в сети {network}. В другой сети средства можно потерять.", "memo-hint-evm": "В этой сети мемо указывается в поле данных, а не как заметка.", "memo-hint-other": "Мемо указывается как мемо транзакции.", "review-min": "Вы получите не менее", @@ -981,6 +981,7 @@ "msg-received-less": "Получено на {percent}% меньше ожидаемого", "msg-received-more": "Получено на {percent}% больше ожидаемого", "msg-wallet-cannot": "Этот кошелёк пока не умеет обменивать через этого провайдера.", + "msg-token-memo": "Нельзя оплатить токеном из другого кошелька: нужно мемо, которое перевод токена не может передать.", "msg-refusal-rate-limited": "Провайдер временно отклоняет запросы. Следующая котировка может пройти.", "msg-refusal-estimate": "Сейчас не удалось оценить этот обмен. Он может появиться в следующей котировке.", "msg-refusal-liquidity": "Сейчас недостаточно ликвидности для этого обмена.", @@ -1130,6 +1131,9 @@ "filter-withfunds": "С помощью средств", "filters": "Фильтры", "empty": "Пока здесь ничего нет.", + "empty-title": "Пока нет операций", + "empty-text": "Получите первые ZEC, и ваша история начнёт расти.", + "empty-receive": "Получить ZEC", "remove": "Удалить", "remove-title": "Удалить", "remove-error": "Error удаления :", diff --git a/app/translations/tr.json b/app/translations/tr.json index b76090b60e..9da558e4f9 100644 --- a/app/translations/tr.json +++ b/app/translations/tr.json @@ -854,11 +854,11 @@ "pay-address-memo": "Adres + not", "review-how-you-pay": "Nasıl ödersiniz", "review-memo-ack": "Cüzdanım {provider} notunu ekleyebilir.", - "qr-token-memo": "{coin} gönderimi bu notu taşıyamaz. {provider} ile bağlanan bir cüzdan kullanın.", "qr-carries-address": "QR kodda yalnızca adres var", "memo-hint-token": "Ödemeyle birlikte gönderin. Borsalar bunu yapamaz.", "exact-amount-exchange": "Borsadan mı? Ücretini üstüne ekleyin. {amount} tutarından az gelirse {provider} iade eder.", "network-only": "{coin}’i yalnızca {network} üzerinden gönderin. Başka bir ağda fonlar kaybolabilir.", + "network-memo-only": "Not destekleyen bir cüzdandan gönderin ve {coin}’i yalnızca {network} üzerinden gönderin. Başka bir ağda fonlar kaybolabilir.", "memo-hint-evm": "Bu ağda not, açıklama olarak değil veri alanına girer.", "memo-hint-other": "Not, işlemin notu olarak gider.", "review-min": "En az alacağınız", @@ -981,6 +981,7 @@ "msg-received-less": "Beklenenden %{percent} daha az alındı", "msg-received-more": "Beklenenden %{percent} daha fazla alındı", "msg-wallet-cannot": "Bu cüzdan henüz bu sağlayıcı üzerinden takas yapamıyor.", + "msg-token-memo": "Başka bir cüzdandan token ile ödenemez: token transferinin taşıyamadığı bir not gerekiyor.", "msg-refusal-rate-limited": "Sağlayıcı şu an istekleri reddediyor. Bir sonraki teklif ulaşabilir.", "msg-refusal-estimate": "Bu takas şu anda fiyatlandırılamadı. Bir sonraki teklifte geri gelebilir.", "msg-refusal-liquidity": "Şu anda bu takas için yeterli likidite yok.", @@ -1130,6 +1131,9 @@ "filter-withfunds": "Varlıklarla birlikte", "filters": "Filtreler", "empty": "Burada henüz bir şey yok.", + "empty-title": "Henüz hareket yok", + "empty-text": "İlk ZEC'ini al, geçmişin büyümeye başlasın.", + "empty-receive": "ZEC al", "remove": "Sil", "remove-title": "Sil", "remove-error": "Error Kaldırma hatası:", diff --git a/screens/History/History.tsx b/screens/History/History.tsx index 26c2b2afaa..914a13a5d7 100644 --- a/screens/History/History.tsx +++ b/screens/History/History.tsx @@ -29,6 +29,7 @@ import { space8, space16, space20, + space24, } from '@app/theme'; import { @@ -44,6 +45,7 @@ import { } from '@app/AppState'; import { HomeTabNavigation, HomeTabScreenProps } from '@app/types'; import FadeText from '@ui/primitives/FadeText'; +import EmptySprout from '@ui/widgets/EmptySprout'; import AppSheet from '@ui/primitives/AppSheet'; import AppSheetModal from '@ui/primitives/AppSheetModal'; import RingBorder from '@ui/primitives/RingBorder'; @@ -88,6 +90,7 @@ import { RPCValueTransfersStatusEnum } from '@app/walletBackend/enums/RPCValueTr import BottomSheet, { BottomSheetModal } from '@gorhom/bottom-sheet'; import Filters, { HISTORY_FILTERS, filterOf } from './components/Filters'; import Text from '@ui/primitives/Text'; +import Button from '@ui/primitives/Button'; import FreshGlow from './components/FreshGlow'; import { useFreshRows } from './useFreshRows'; import { FiltersIcon } from '@ui/primitives/Icons/FiltersIcon'; @@ -826,34 +829,53 @@ const History: React.FunctionComponent = () => { } }; + /** Nothing to filter: no filter button, and none left on from before. */ + const noHistory = mergedValueTransfers.length === 0; + useEffect(() => { + if (noHistory) { + setFilterKind(null); + setFilterFailed(false); + setFilterMemos(false); + setFilterWithFunds(false); + } + }, [noHistory]); + const historyHeader = ( { - setShowFilters(true); - bottomSheetRef.current?.present(); - }, - badge: - !!filterKind || filterFailed || filterMemos || filterWithFunds ? ( - - ) : undefined, - }} + right={ + noHistory + ? undefined + : { + icon: FiltersIcon, + testID: 'history.filters', + accessibilityLabel: translate('history.filters') as string, + onPress: () => { + setShowFilters(true); + bottomSheetRef.current?.present(); + }, + badge: + !!filterKind || + filterFailed || + filterMemos || + filterWithFunds ? ( + + ) : undefined, + } + } /> ); @@ -943,7 +965,7 @@ const History: React.FunctionComponent = () => { /> ) : ( - {activeFilter !== 'all' && ( + {activeFilter !== 'all' && !noHistory && ( = () => { {fades} + ) : noHistory ? ( + + + + {translate('history.empty-title') as string} + + + {translate('history.empty-text') as string} + +