diff --git a/README.md b/README.md index 6081a70..58e3069 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ See [docs/adr-nfc-library.md](docs/adr-nfc-library.md) for platform constraints - [Product flows & system definition](docs/ding-payments.md) - [Client MVP build plan](docs/build-plan-client-mvp.md) +- [Wallet flow (C09)](docs/wallet-flow.md) - [NFC library ADR](docs/adr-nfc-library.md) - [Receive payment flow (C12)](docs/receive-flow.md) - [NFC runtime flow and troubleshooting](docs/nfc-flow.md) diff --git a/docs/wallet-flow.md b/docs/wallet-flow.md new file mode 100644 index 0000000..f2263b4 --- /dev/null +++ b/docs/wallet-flow.md @@ -0,0 +1,402 @@ +# Wallet Flow (C09) + +The wallet domain provides self-custodial Stellar key management, testnet funding, +USDC trustline setup, balance reads, payment validation, unsigned transaction +building, and affordability checks. C09 completes the primitives required for +future send/receive payment flows without shipping a full end-to-end send UI yet. + +## 1. Overview + +C09 delivers the wallet **service layer** and **onboarding integration** for Ding +Payments: + +- **Account lifecycle** — generate or reuse a Stellar keypair, check on-chain + existence, fund on testnet via Friendbot. +- **Trustline & balances** — idempotent USDC trustline creation during setup; + Horizon balance parsing for XLM and USDC. +- **Payment primitives** — Zod-validated payment params, unsigned + `TransactionBuilder`, and `FeeService` affordability checks using stroops/bigint. +- **Error taxonomy** — stable `WalletErrorCode` values via `walletErrors.ts`. +- **Auth integration** — `AuthGuard` gates protected tabs on local wallet readiness; + Settings shows the Stellar G-address; logout clears Stellar keys and resets + `walletStore`. + +What C09 does **not** include: a complete send flow (sign + submit from UI), +receive settlement polling, or on-chain funding verification inside `AuthGuard`. + +## 2. Onboarding sequence + +The real onboarding path after passkey registration: + +1. **Passkey** — `useAuth.registerPasskey()` persists `PASSKEY_CREDENTIAL_ID` and + `WALLET_PUBLIC_KEY` (credential ID placeholder), then navigates to + `/(onboarding)/wallet-setup`. +2. **Keypair** — `useWallet.createWallet()` calls + `AccountService.getOrCreateKeypair()` (reads or generates + `WALLET_STELLAR_PUBLIC_KEY` / `WALLET_STELLAR_SECRET_KEY` in SecureKeyStore). +3. **Funding** — on **testnet**, `AccountService.fundTestnetAccount()` calls + `StellarHorizonClient.fundWithFriendbot()`. On **mainnet**, if the account + does not exist on Horizon, status becomes `awaiting_funding` and the user must + deposit XLM externally (`FundWalletView` + `checkFunding()`). +4. **Trustline** — `finishSetup()` calls `ensureUsdcTrustline()` (signs and + submits `changeTrust` when needed). +5. **Balances** — `fetchBalances()` loads XLM/USDC from Horizon into + `walletStore`. +6. **Ready** — `walletStore.status` becomes `ready`; `WalletSetupView` redirects + to `/(tabs)/receive`. + +### Distinctions + +| Step | What it does | What it does **not** do | +| --- | --- | --- | +| Keypair creation/reuse | Generates or reads local Stellar G/S keys | Does not activate the account on-chain by itself | +| Friendbot funding | Creates/funds testnet account via Horizon | Not available on mainnet | +| USDC trustline | Adds `changeTrust` for configured issuer | Does not run during receive read-only checks | +| Balance load | Parses Horizon `balances` into `WalletBalances` | Does not poll for incoming payments | +| Wallet ready | Local state: key present + setup finished | Does not prove ongoing on-chain solvency after hydration | + +### Sequence diagram (current code) + +```mermaid +sequenceDiagram + participant User + participant Passkey as useAuth / PasskeyService + participant Setup as WalletSetupView / useWallet + participant Acct as AccountService + participant Horizon as StellarHorizonClient + participant Trust as ensureUsdcTrustline + participant Bal as BalanceService + participant Store as walletStore + + User->>Passkey: registerPasskey() + Passkey->>User: navigate to wallet-setup + + User->>Setup: createWallet() + Setup->>Store: status = creating + Setup->>Acct: getOrCreateKeypair() + Acct-->>Setup: publicKey + + alt testnet + Setup->>Acct: fundTestnetAccount(publicKey) + Acct->>Horizon: fundWithFriendbot() + Horizon-->>Acct: funded / already_funded + else mainnet, account missing + Acct->>Horizon: loadAccount() + Horizon-->>Acct: NotFoundError + Setup->>Store: status = awaiting_funding + end + + Setup->>Trust: ensureUsdcTrustline(publicKey) + Trust->>Horizon: loadAccount / submitTransaction (changeTrust) + Setup->>Bal: fetchBalances(publicKey) + Bal->>Horizon: loadAccount() + Setup->>Store: balances, status = ready +``` + +### Cold-start hydration (separate path) + +On app launch, `useWallet` reads `WALLET_STELLAR_PUBLIC_KEY` from SecureKeyStore. +If a key exists locally, it sets `publicKey` and `status = 'ready'` **without** +re-running funding checks or trustline setup. Balances are fetched lazily when +`status === 'ready'` and `balances` is null. + +## 3. Authentication and wallet readiness + +`AuthGuard` wraps the tab navigator (`src/app/(tabs)/_layout.tsx`) and runs +**after** auth state is resolved. It performs **no network calls**. + +### Auth states handled + +| Auth status | Guard behavior | +| --- | --- | +| `LOADING` | Renders nothing (splash handled elsewhere) | +| `UNAUTHENTICATED` | Redirect → `/(onboarding)/welcome` | +| `ONBOARDING` | Redirect → `/(onboarding)/create-passkey` | +| `LOCKED` | Redirect → `/(onboarding)/locked` | +| `READY` | Requires wallet readiness (see below) | + +### Wallet-ready criteria (current) + +When `auth.status === 'READY'`, the guard allows tab content only if: + +```text +hasHydrated && status === 'ready' && publicKey +``` + +Otherwise it redirects to `/(onboarding)/wallet-setup`. + +**Important:** This checks **local** wallet state only. The guard does **not** +verify on-chain funding, trustline presence, or balance sufficiency. + +### Loop safety + +`/(onboarding)/wallet-setup` lives under `(onboarding)/`, **outside** the +`(tabs)/` stack that `AuthGuard` protects. A user without a ready wallet is sent +to wallet-setup instead of bouncing between tabs and onboarding auth screens. + +## 4. Wallet state machine + +`walletStore` (`src/features/wallet/state/walletStore.ts`) defines: + +| Status | Meaning | +| --- | --- | +| `idle` | Initial state before hydration or before create | +| `creating` | `createWallet()` in progress | +| `awaiting_funding` | Key exists locally; mainnet account not found on Horizon | +| `ready` | Setup complete (or hydrated from stored public key) | +| `error` | Setup failed; message in `walletStore.error` | + +### State diagram + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> ready: hydration finds WALLET_STELLAR_PUBLIC_KEY + idle --> creating: createWallet() + creating --> ready: funding OK + finishSetup() + creating --> awaiting_funding: mainnet account not on Horizon + creating --> error: funding/setup failure + awaiting_funding --> ready: checkFunding() finds account + finishSetup() + error --> creating: user retries createWallet() + ready --> idle: walletStore.reset() (logout) +``` + +### Main transitions (implementation) + +- **`createWallet`** — `idle|error` → `creating` → `ready` | `awaiting_funding` | `error` +- **`checkFunding`** — `awaiting_funding` → `ready` when `accountExistsOnNetwork` is true +- **Hydration** — `idle` → `ready` when a Stellar public key exists in SecureKeyStore +- **`reset` (logout)** — any → `idle` (full initial state, `hasHydrated: false`) + +## 5. Service ownership + +> **AccountService** fulfills the role described as **WalletService** in the C09 +> specification. There is **no separate `WalletService.ts`** in this repository. + +| Module | Responsibility | Main dependencies | +| --- | --- | --- | +| **AccountService** | Keypair get/create, on-chain existence check, testnet Friendbot funding | `SecureKeyStore`, `StellarHorizonClient`, `env` | +| **StellarHorizonClient** | Typed wrapper: `loadAccount`, `submitTransaction`, `fundWithFriendbot` | `@stellar/stellar-sdk` Horizon.Server, `env.horizonUrl` | +| **BalanceService** | Parse Horizon balances; fetch XLM/USDC; stroops helpers | `StellarHorizonClient`, `stellarReserve`, `STELLAR_ASSETS` | +| **TrustlineService** | `ensureUsdcTrustline` (setup); `checkUsdcTrustline` (read-only for receive) | `StellarHorizonClient`, `BalanceService`, `stellarReserve`, `walletErrors`, `SecureKeyStore` | +| **FeeService** | `estimateFee()`, `canAffordPayment()` — no submission | `BalanceService`, `TrustlineService.hasUsdcTrustline`, `paymentTx`, `StellarHorizonClient` | +| **TransactionBuilder** | Build **unsigned** XLM/USDC payment transactions | `paymentTx`, `StellarHorizonClient`, `env.networkPassphrase` | +| **walletErrors** | Map Horizon/SDK/validation failures to `WalletErrorCode` + Spanish messages | `paymentTx`, `FeeService`, `TrustlineService` types | +| **stellarReserve** | Reserve formulas and stroops conversion (shared constants) | None (pure utils) | +| **paymentTx** | Zod schema, amount parsing (bigint stroops), validation errors | `STELLAR_ASSETS` | +| **useWallet / walletStore** | UI hook + Zustand store: hydration, create, funding check, balances | `AccountService`, `TrustlineService`, `BalanceService`, `SecureKeyStore` | + +## 6. Secure key and public key model + +The app maintains **two unrelated public identifiers**: + +| Identity | Storage key | Used for | +| --- | --- | --- | +| **Passkey credential ID** | `SECURE_KEYS.PASSKEY_CREDENTIAL_ID` | WebAuthn / passkey auth | +| **Auth “publicKey”** | `SECURE_KEYS.WALLET_PUBLIC_KEY` / `authStore.publicKey` | Passkey credential ID at runtime — **not** a Stellar G-address | +| **Stellar public key** | `SECURE_KEYS.WALLET_STELLAR_PUBLIC_KEY` / `walletStore.publicKey` | On-chain Stellar account (G…) | +| **Stellar secret key** | `SECURE_KEYS.WALLET_STELLAR_SECRET_KEY` | Signing (`ensureUsdcTrustline`; future send) — never exposed to UI | + +`authStore.publicKey` is documented in code as the passkey credential ID placeholder. +It must **not** be shown as the Stellar wallet address. + +**Settings** (`SettingsAuthSection`) displays the Stellar G-address from +`useWallet().publicKey` (wallet domain), not `useAuth().state.publicKey`. + +## 7. Payment transaction flow + +### Implemented in C09 + +```text +paymentTx schema (validate + parsePaymentAmount) + → TransactionBuilder.buildPaymentTx() [unsigned tx] + → FeeService.canAffordPayment() [affordability only] +``` + +- **Validation** — `PaymentTxValidationError` from Zod / amount rules before any + Horizon call. +- **Build** — loads source account sequence from Horizon; returns unsigned + `Transaction` (fee = `BASE_FEE`, default timeout 300s). +- **Affordability** — `AffordabilityResult` with `canAfford` and optional + `AffordabilityReason`; uses payment reserve formula (no trustline buffer). + +### Not implemented (future work) + +```text + → signing with WALLET_STELLAR_SECRET_KEY [future] + → StellarHorizonClient.submitTransaction() [future send UI / payer flow] +``` + +There is **no** production send screen wired to `buildPaymentTx` today. Receive +(C12) uses NFC + trustline checks but does not submit payer transactions from this +stack yet. + +## 8. Reserve formulas + +Implemented in `src/features/wallet/utils/stellarReserve.ts`. + +### Payments (affordability / minimum balance) + +```text +minimumBalance = (2 + subentryCount) × 0.5 XLM +``` + +Used by `getMinimumBalanceStroops()` → `FeeService.canAffordPayment()` and +`BalanceService.getMinimumBalanceStroops`. **No** extra subentry and **no** +0.01 XLM buffer. + +### Trustline creation + +```text +minimumBalance = (2 + subentryCount + 1) × 0.5 XLM + 0.01 XLM buffer +``` + +Used by `getMinimumBalanceStroopsForNewTrustline()` and +`getMinimumBalanceXlmForNewTrustline()` in `ensureUsdcTrustline()`. The +**0.01 XLM buffer applies only to trustline creation**, not payment affordability. + +### Stroops / bigint + +Monetary math uses **stroops** (`1 XLM = 10^7 stroops`) via `horizonBalanceToStroops`, +`parsePaymentAmount`, and `FeeService` to avoid JavaScript floating-point drift. +`getMinimumBalanceXlmForNewTrustline()` remains a Number helper for legacy checks +inside `TrustlineService.hasSufficientReserveForTrustline()`. + +## 9. Error taxonomy + +Keep Horizon/SDK details in services; expose stable codes/messages to UI via +`walletErrors.ts` (and `toast.walletError()` where integrated). + +### Layers + +| Layer | Type | When | +| --- | --- | --- | +| Input validation | `PaymentTxValidationError` | Invalid keys, asset, amount, memo before build | +| Build failures | `PaymentTxBuildError` | Source account load failure, missing USDC issuer config | +| Affordability | `AffordabilityReason` | Pre-flight balance/trustline/reserve checks (`FeeService`) | +| User-facing stable | `WalletErrorCode` / `WalletError` | Mapped Horizon, affordability, and validation failures | + +### Mapping helpers + +| Function | Purpose | +| --- | --- | +| `mapHorizonError(error)` | NotFound → `ACCOUNT_NOT_FOUND`; result codes → balance/trustline/recipient; network → `NETWORK_ERROR` | +| `mapAffordabilityReason(reason)` | Bridges `FeeService` reasons to `WalletErrorCode` | +| `mapPaymentValidationError(error)` | `PaymentTxValidationError` → `INVALID_AMOUNT` or `INVALID_PAYMENT_PARAMS` | +| `sanitizeWalletError(err)` | Strip `cause` before UI/analytics | + +`TransactionBuilder` collapses all `loadAccount` failures into a single +`PaymentTxBuildError` message; callers that need `ACCOUNT_NOT_FOUND` vs +`NETWORK_ERROR` should map the **original** Horizon error with `mapHorizonError` +at the integration boundary. + +### WalletErrorCode reference + +| Code | Typical source | +| --- | --- | +| `ACCOUNT_NOT_FOUND` | Horizon `NotFoundError`, affordability `account_not_found` | +| `INVALID_RECIPIENT` | Horizon `op_no_destination` | +| `INSUFFICIENT_BALANCE` | Horizon underfund / line full; affordability `insufficient_balance` | +| `INSUFFICIENT_RESERVE` | Affordability `insufficient_xlm_for_fee_and_reserve`; trustline reserve | +| `NO_USDC_TRUSTLINE` | Horizon trust ops; affordability `no_usdc_trustline` | +| `INVALID_AMOUNT` | Amount validation messages from `paymentTx` | +| `INVALID_PAYMENT_PARAMS` | Other payment schema validation failures | +| `BAD_SEQUENCE` | Horizon `tx_bad_seq` | +| `TRANSACTION_FAILED` | Other failed transaction result codes | +| `NETWORK_ERROR` | HTTP 5xx, `TypeError`, affordability `network_error` | +| `TIMEOUT` | `AbortError` | +| `WALLET_KEY_MISSING` | Missing `WALLET_STELLAR_SECRET_KEY` during trustline setup | +| `CONFIG_ERROR` | Reserved for misconfiguration (e.g. USDC issuer) | +| `UNSUPPORTED_OPERATION` | Environment restrictions | +| `UNKNOWN` | Unclassified errors | + +## 10. Environment variables + +Defined in `src/lib/env.ts` (loaded from Expo public env vars): + +| Variable | Purpose | +| --- | --- | +| `EXPO_PUBLIC_STELLAR_NETWORK` | `testnet` or `mainnet` — selects network profile and passphrase | +| `EXPO_PUBLIC_HORIZON_URL` | Horizon HTTP endpoint for `StellarHorizonClient` | +| `EXPO_PUBLIC_RPC_URL` | Soroban RPC URL (typed in `env`; wallet C09 uses Horizon primarily) | +| `EXPO_PUBLIC_USDC_ISSUER` | USDC issuer account ID for trustline and balance matching | + +Derived at runtime (not env vars): + +- `env.networkPassphrase` — `Networks.TESTNET` or `Networks.PUBLIC` +- `env.stellarNetwork`, `env.horizonUrl`, `env.rpcUrl`, `env.usdcIssuer` + +Mainnet guardrail: if `EXPO_PUBLIC_STELLAR_NETWORK=mainnet`, Horizon/RPC URLs +must not contain `testnet`. + +## 11. Logout and wallet cleanup + +`useAuth.logout()` performs: + +1. `PasskeyService.revoke()` — clears passkey credential artifacts +2. Deletes `WALLET_STELLAR_PUBLIC_KEY`, `WALLET_STELLAR_SECRET_KEY`, and + `SESSION_LAST_ACTIVE` from SecureKeyStore +3. `useWalletStore.getState().reset()` — clears status, publicKey, balances, + error, and `hasHydrated` +4. `dispatch({ type: 'LOGOUT' })` — auth returns to `UNAUTHENTICATED` + +Passkey keys (`PASSKEY_CREDENTIAL_ID`, `WALLET_PUBLIC_KEY`) are revoked via +PasskeyService rather than deleted individually in this flow. + +This prevents a previous session’s Stellar key from leaving `walletStore` in +`ready` after logout: the next user must pass auth and run wallet setup again. +`AuthGuard` will redirect to wallet-setup until a new wallet reaches `ready`. + +## 12. Testing + +C09 unit/integration tests use **mocked** Horizon and SecureKeyStore — no live +network required. + +| Area | Test file | +| --- | --- | +| TransactionBuilder | `src/features/wallet/services/__tests__/TransactionBuilder.test.ts` | +| stellarReserve | `src/features/wallet/utils/stellarReserve.test.ts` | +| AccountService | `src/features/wallet/services/AccountService.test.ts` | +| FeeService | `src/features/wallet/services/FeeService.test.ts` | +| BalanceService | `src/features/wallet/services/BalanceService.test.ts` | +| TrustlineService | `src/features/wallet/services/TrustlineService.test.ts` | +| walletErrors | `src/features/wallet/services/walletErrors.test.ts` | +| StellarHorizonClient | `src/features/wallet/services/StellarHorizonClient.test.ts` | +| AuthGuard | `src/features/auth/components/__tests__/AuthGuard.test.tsx` | +| Settings Stellar pubkey | `src/features/auth/components/__tests__/SettingsAuthSection.test.tsx` | +| Logout wallet cleanup | `src/features/auth/hooks/__tests__/useAuth.logout.test.tsx` | + +Run the C09 regression slice: + +```bash +npm test -- --testPathPattern="TransactionBuilder|stellarReserve|AccountService|FeeService|BalanceService|TrustlineService|walletErrors|StellarHorizonClient|AuthGuard|SettingsAuthSection|useAuth.logout" +``` + +## 13. Manual testnet checklist + +Use a **development build** on Stellar **testnet** with valid `.env` values. + +- [ ] **Create / reuse wallet** — Complete passkey onboarding → wallet-setup → + “Crear billetera”. Confirm a G-address appears in Settings after ready. +- [ ] **Testnet funding** — New account receives Friendbot XLM (or shows + already funded). Mainnet: confirm `awaiting_funding` until external deposit. +- [ ] **USDC trustline** — After funding, account holds USDC trustline (check + Horizon or balances in app). Trustline errors surface user-safe messages. +- [ ] **Balance read** — XLM and USDC balances load after ready; pull-to-refresh + / re-entry updates via `refreshBalances()`. +- [ ] **Insufficient funds (affordability)** — Call `FeeService.canAffordPayment` + (or future send UI) with amount exceeding balance; expect + `insufficient_balance` or `insufficient_xlm_for_fee_and_reserve`. +- [ ] **Invalid destination** — `buildPaymentTx` with invalid G-address throws + `PaymentTxValidationError` before Horizon. +- [ ] **Logout cleanup** — Logout → Stellar pubkey gone from Settings → accessing + tabs redirects to wallet-setup → new wallet flow does not reuse old keys. + +**Not in scope for manual send verification:** end-to-end XLM/USDC payment submit +from a Send screen — signing and submission are not wired in the current UI. + +## 14. Related documentation + +- [Stellar SDK ADR](adr-stellar-sdk.md) — SDK choice, polyfills, dev-client requirements +- [Product flows & system definition](ding-payments.md) — overall MVP scope +- [Receive payment flow (C12)](receive-flow.md) — receive FSM and trustline read checks diff --git a/src/constants/analytics-events.ts b/src/constants/analytics-events.ts index b811f34..10b25ee 100644 --- a/src/constants/analytics-events.ts +++ b/src/constants/analytics-events.ts @@ -16,6 +16,9 @@ export const AnalyticsEvents = { NFC_READ_FAILURE: 'nfc_read_failure', NFC_WRITE_SUCCESS: 'nfc_write_success', NFC_WRITE_FAILURE: 'nfc_write_failure', + + // Wallet errors (CLI-041) + WALLET_ERROR: 'wallet_error', } as const; export type AnalyticsEventName = (typeof AnalyticsEvents)[keyof typeof AnalyticsEvents]; @@ -33,3 +36,8 @@ export interface ReceiveEventProperties { reason?: string; duration_ms?: number; } + +/** Allowed properties for wallet error events. Code only — no cause or Horizon payloads. */ +export interface WalletEventProperties { + code: string; +} diff --git a/src/features/auth/components/AuthGuard.tsx b/src/features/auth/components/AuthGuard.tsx index 7230703..efb2e5f 100644 --- a/src/features/auth/components/AuthGuard.tsx +++ b/src/features/auth/components/AuthGuard.tsx @@ -1,29 +1,31 @@ /** - * CLI-019 — AuthGuard + * CLI-019 / CLI-042 — AuthGuard * - * Route guard component that redirects users based on auth state. - * Prevents protected tabs from rendering when auth is incomplete. + * Route guard for protected tabs: auth state first, then local wallet readiness. * * State → redirect rules: * - LOADING → render nothing (splash is shown by AnimatedSplashOverlay) * - UNAUTHENTICATED → redirect to onboarding welcome * - ONBOARDING → redirect to create-passkey * - LOCKED → redirect to locked screen (re-auth) - * - READY → render children + * - READY → require walletStore hydrated + status ready + publicKey * - * Loop safety: uses replace semantics so the back button doesn't loop. + * Loop safety: uses replace semantics; wallet-setup lives outside (tabs) guard. */ import { Redirect } from 'expo-router'; +import { useWalletStore } from '@/features/wallet/state/walletStore'; import { useAuth } from '../hooks/useAuth'; export function AuthGuard({ children }: { children: React.ReactNode }) { const { state } = useAuth(); + const hasHydrated = useWalletStore((walletState) => walletState.hasHydrated); + const walletStatus = useWalletStore((walletState) => walletState.status); + const walletPublicKey = useWalletStore((walletState) => walletState.publicKey); switch (state.status) { case 'LOADING': - // Splash overlay is handling the loading state return null; case 'UNAUTHENTICATED': @@ -36,6 +38,14 @@ export function AuthGuard({ children }: { children: React.ReactNode }) { return ; case 'READY': + if (!hasHydrated) { + return null; + } + + if (walletStatus !== 'ready' || !walletPublicKey) { + return ; + } + return <>{children}; default: diff --git a/src/features/auth/components/SettingsAuthSection.tsx b/src/features/auth/components/SettingsAuthSection.tsx index 46c85e7..74d30c7 100644 --- a/src/features/auth/components/SettingsAuthSection.tsx +++ b/src/features/auth/components/SettingsAuthSection.tsx @@ -16,15 +16,15 @@ import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Button } from '@/components/ui/Button'; import { ThemedText } from '@/components/themed-text'; import { useTheme } from '@/hooks/use-theme'; +import { useWallet } from '@/features/wallet/hooks/useWallet'; import { useAuth } from '../hooks/useAuth'; export function SettingsAuthSection() { - const { state, logout } = useAuth(); + const { logout } = useAuth(); + const { publicKey } = useWallet(); const theme = useTheme(); const [pubkeyCopied, setPubkeyCopied] = useState(false); - const publicKey = state.publicKey; - const handleLogout = useCallback(() => { Alert.alert( 'Cerrar sesión', @@ -95,7 +95,7 @@ export function SettingsAuthSection() { ) : ( - Sin llave de acceso registrada + Billetera no configurada )} diff --git a/src/features/auth/components/__tests__/AuthGuard.test.tsx b/src/features/auth/components/__tests__/AuthGuard.test.tsx new file mode 100644 index 0000000..373f3cc --- /dev/null +++ b/src/features/auth/components/__tests__/AuthGuard.test.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +import { useWalletStore } from '@/features/wallet/state/walletStore'; +import { AuthGuard } from '../AuthGuard'; +import { useAuth } from '../../hooks/useAuth'; +import type { AuthState } from '../../state/authStore'; + +jest.mock('expo-router', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + Redirect: ({ href }: { href: string }) => + React.createElement(Text, { testID: 'redirect' }, href), + }; +}); + +jest.mock('../../hooks/useAuth', () => ({ + useAuth: jest.fn(), +})); + +const mockUseAuth = useAuth as jest.Mock; + +function setAuthState(status: AuthState['status']) { + mockUseAuth.mockReturnValue({ + state: { status } as AuthState, + }); +} + +function resetWalletStore() { + useWalletStore.getState().reset(); +} + +async function renderGuard() { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create( + + Tabs + + ); + }); + return renderer; +} + +describe('AuthGuard', () => { + beforeEach(() => { + jest.clearAllMocks(); + resetWalletStore(); + }); + + it('returns null while auth is LOADING', async () => { + setAuthState('LOADING'); + + const renderer = await renderGuard(); + + expect(renderer.root.findAllByProps({ testID: 'protected-content' })).toHaveLength(0); + expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0); + }); + + it('redirects UNAUTHENTICATED users to welcome', async () => { + setAuthState('UNAUTHENTICATED'); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/welcome' + ); + }); + + it('redirects ONBOARDING users to create-passkey', async () => { + setAuthState('ONBOARDING'); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/create-passkey' + ); + }); + + it('redirects LOCKED users to locked', async () => { + setAuthState('LOCKED'); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/locked' + ); + }); + + it('returns null for READY auth while wallet is not hydrated', async () => { + setAuthState('READY'); + useWalletStore.setState({ hasHydrated: false, status: 'idle', publicKey: null }); + + const renderer = await renderGuard(); + + expect(renderer.root.findAllByProps({ testID: 'protected-content' })).toHaveLength(0); + expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0); + }); + + it('redirects READY auth to wallet-setup when wallet status is not ready', async () => { + setAuthState('READY'); + useWalletStore.setState({ + hasHydrated: true, + status: 'creating', + publicKey: 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO', + }); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/wallet-setup' + ); + }); + + it('redirects READY auth to wallet-setup when wallet publicKey is missing', async () => { + setAuthState('READY'); + useWalletStore.setState({ + hasHydrated: true, + status: 'ready', + publicKey: null, + }); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/wallet-setup' + ); + }); + + it('renders children when auth is READY and wallet is ready with a publicKey', async () => { + setAuthState('READY'); + useWalletStore.setState({ + hasHydrated: true, + status: 'ready', + publicKey: 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO', + }); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'protected-content' }).props.children).toBe('Tabs'); + expect(renderer.root.findAllByProps({ testID: 'redirect' })).toHaveLength(0); + }); + + it('does not apply wallet checks outside READY auth', async () => { + setAuthState('LOCKED'); + useWalletStore.setState({ + hasHydrated: false, + status: 'idle', + publicKey: null, + }); + + const renderer = await renderGuard(); + + expect(renderer.root.findByProps({ testID: 'redirect' }).props.children).toBe( + '/(onboarding)/locked' + ); + }); +}); diff --git a/src/features/auth/components/__tests__/SettingsAuthSection.test.tsx b/src/features/auth/components/__tests__/SettingsAuthSection.test.tsx new file mode 100644 index 0000000..0ed6d52 --- /dev/null +++ b/src/features/auth/components/__tests__/SettingsAuthSection.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +import { useWalletStore } from '@/features/wallet/state/walletStore'; +import { SettingsAuthSection } from '../SettingsAuthSection'; +import { useAuth } from '../../hooks/useAuth'; + +jest.mock('../../hooks/useAuth', () => ({ + useAuth: jest.fn(), +})); + +jest.mock('@/features/wallet/hooks/useWallet', () => ({ + useWallet: jest.fn(), +})); + +jest.mock('@/hooks/use-theme', () => ({ + useTheme: () => ({ + textSecondary: '#666', + backgroundElement: '#eee', + }), +})); + +jest.mock('@/components/themed-text', () => { + const React = require('react'); + const { Text } = require('react-native'); + + return { + ThemedText: ({ + children, + ...props + }: { + children?: React.ReactNode; + accessibilityLabel?: string; + }) => React.createElement(Text, props, children), + }; +}); + +jest.mock('@/components/ui/Button', () => { + const React = require('react'); + const { Pressable, Text } = require('react-native'); + + return { + Button: ({ label, onPress }: { label: string; onPress: () => void }) => + React.createElement( + Pressable, + { onPress, accessibilityLabel: label }, + React.createElement(Text, null, label) + ), + }; +}); + +import { useWallet } from '@/features/wallet/hooks/useWallet'; + +const mockUseAuth = useAuth as jest.Mock; +const mockUseWallet = useWallet as jest.Mock; + +const STELLAR_PUBLIC_KEY = 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO'; +const PASSKEY_CREDENTIAL_ID = 'credential-id-not-a-stellar-address'; + +async function renderSettings() { + let renderer!: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + return renderer; +} + +describe('SettingsAuthSection', () => { + beforeEach(() => { + jest.clearAllMocks(); + useWalletStore.getState().reset(); + mockUseAuth.mockReturnValue({ + logout: jest.fn(), + state: { + publicKey: PASSKEY_CREDENTIAL_ID, + }, + }); + }); + + it('displays the Stellar wallet public key from useWallet', async () => { + mockUseWallet.mockReturnValue({ + publicKey: STELLAR_PUBLIC_KEY, + status: 'ready', + balances: null, + error: null, + isReady: true, + createWallet: jest.fn(), + checkFunding: jest.fn(), + refreshBalances: jest.fn(), + }); + + const renderer = await renderSettings(); + const tree = renderer.toJSON(); + + expect(JSON.stringify(tree)).toContain(STELLAR_PUBLIC_KEY); + expect(JSON.stringify(tree)).not.toContain(PASSKEY_CREDENTIAL_ID); + }); + + it('shows an empty wallet state when no Stellar public key exists', async () => { + mockUseWallet.mockReturnValue({ + publicKey: null, + status: 'idle', + balances: null, + error: null, + isReady: false, + createWallet: jest.fn(), + checkFunding: jest.fn(), + refreshBalances: jest.fn(), + }); + + const renderer = await renderSettings(); + + expect(JSON.stringify(renderer.toJSON())).toContain('Billetera no configurada'); + expect(JSON.stringify(renderer.toJSON())).not.toContain(PASSKEY_CREDENTIAL_ID); + }); + + it('supports copying feedback when a wallet public key is present', async () => { + jest.useFakeTimers(); + mockUseWallet.mockReturnValue({ + publicKey: STELLAR_PUBLIC_KEY, + status: 'ready', + balances: null, + error: null, + isReady: true, + createWallet: jest.fn(), + checkFunding: jest.fn(), + refreshBalances: jest.fn(), + }); + + const renderer = await renderSettings(); + const pubkeyTouchable = renderer.root.findByProps({ + accessibilityLabel: 'Copiar clave pública', + }); + + await act(async () => { + pubkeyTouchable.props.onPress(); + }); + + expect( + renderer.root.findByProps({ accessibilityLabel: 'Clave pública copiada' }) + ).toBeTruthy(); + + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + jest.useRealTimers(); + }); +}); diff --git a/src/features/auth/hooks/__tests__/useAuth.logout.test.tsx b/src/features/auth/hooks/__tests__/useAuth.logout.test.tsx new file mode 100644 index 0000000..2ad815e --- /dev/null +++ b/src/features/auth/hooks/__tests__/useAuth.logout.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; + +import { SecureKeyStore } from '@/lib/SecureKeyStore'; +import { SECURE_KEYS } from '@/lib/SecureKeyStore.types'; +import { useWalletStore } from '@/features/wallet/state/walletStore'; +import { PasskeyService } from '../../services/PasskeyService'; +import { AuthProvider, useAuth } from '../useAuth'; + +jest.mock('../../services/PasskeyService', () => ({ + PasskeyService: { + register: jest.fn(), + authenticate: jest.fn(), + revoke: jest.fn(), + }, +})); + +jest.mock('@/lib/SecureKeyStore', () => ({ + SecureKeyStore: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn().mockResolvedValue(undefined), + }, +})); + +jest.mock('@/lib/toast', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + walletError: jest.fn(), + }, +})); + +const mockRevoke = PasskeyService.revoke as jest.Mock; +const mockSecureDelete = SecureKeyStore.delete as jest.Mock; +const mockSecureGet = SecureKeyStore.get as jest.Mock; + +function LogoutProbe({ onReady }: { onReady: (logout: () => Promise) => void }) { + const { logout, state } = useAuth(); + + React.useEffect(() => { + onReady(logout); + }, [logout, onReady]); + + return <>{state.status}; +} + +describe('useAuth logout', () => { + beforeEach(() => { + jest.clearAllMocks(); + useWalletStore.getState().reset(); + mockRevoke.mockImplementation(async () => { + await SecureKeyStore.delete(SECURE_KEYS.PASSKEY_CREDENTIAL_ID); + await SecureKeyStore.delete(SECURE_KEYS.WALLET_PUBLIC_KEY); + await SecureKeyStore.delete(SECURE_KEYS.AUTH_STATE); + return { success: true }; + }); + mockSecureGet.mockResolvedValue(null); + }); + + it('clears passkey, Stellar keys, session, wallet store, and auth state', async () => { + useWalletStore.setState({ + status: 'ready', + publicKey: 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO', + hasHydrated: true, + }); + + let logoutFn: (() => Promise) | undefined; + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create( + + { + logoutFn = logout; + }} + /> + + ); + }); + + await act(async () => { + await logoutFn?.(); + }); + + expect(JSON.stringify(renderer.toJSON())).toContain('UNAUTHENTICATED'); + + expect(mockRevoke).toHaveBeenCalledTimes(1); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.WALLET_STELLAR_PUBLIC_KEY); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.WALLET_STELLAR_SECRET_KEY); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.SESSION_LAST_ACTIVE); + + const walletState = useWalletStore.getState(); + expect(walletState.status).toBe('idle'); + expect(walletState.publicKey).toBeNull(); + expect(walletState.hasHydrated).toBe(false); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.PASSKEY_CREDENTIAL_ID); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.WALLET_PUBLIC_KEY); + expect(mockSecureDelete).toHaveBeenCalledWith(SECURE_KEYS.AUTH_STATE); + }); +}); diff --git a/src/features/auth/hooks/useAuth.tsx b/src/features/auth/hooks/useAuth.tsx index 4390cfe..b969b3f 100644 --- a/src/features/auth/hooks/useAuth.tsx +++ b/src/features/auth/hooks/useAuth.tsx @@ -14,6 +14,7 @@ import React, { createContext, useCallback, useContext, useEffect, useReducer } import { SecureKeyStore } from '@/lib/SecureKeyStore'; import { SECURE_KEYS } from '@/lib/SecureKeyStore.types'; import { toast } from '@/lib/toast'; +import { useWalletStore } from '@/features/wallet/state/walletStore'; import { AuthErrorCode, sanitizeAuthError } from '../services/authErrors'; import { PasskeyService } from '../services/PasskeyService'; import { INITIAL_AUTH_STATE, authReducer } from '../state/authStore'; @@ -147,6 +148,14 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { // ── Logout ────────────────────────────────────────────────────────────────── const logout = useCallback(async () => { await PasskeyService.revoke(); + + await Promise.all([ + SecureKeyStore.delete(SECURE_KEYS.WALLET_STELLAR_PUBLIC_KEY).catch(() => null), + SecureKeyStore.delete(SECURE_KEYS.WALLET_STELLAR_SECRET_KEY).catch(() => null), + SecureKeyStore.delete(SECURE_KEYS.SESSION_LAST_ACTIVE).catch(() => null), + ]); + + useWalletStore.getState().reset(); dispatch({ type: 'LOGOUT' }); }, []); diff --git a/src/features/auth/state/authStore.ts b/src/features/auth/state/authStore.ts index 5f74307..a44570a 100644 --- a/src/features/auth/state/authStore.ts +++ b/src/features/auth/state/authStore.ts @@ -19,7 +19,7 @@ export type AuthStatus = 'LOADING' | 'UNAUTHENTICATED' | 'ONBOARDING' | 'READY' export interface AuthState { status: AuthStatus; - /** Wallet public key (Stellar address) — null until wallet is created */ + /** Passkey credential ID (WALLET_PUBLIC_KEY) — not a Stellar G-address */ publicKey: string | null; /** Passkey credential ID — null until passkey is registered */ credentialId: string | null; diff --git a/src/features/receive/views/ReceiveHomeView.tsx b/src/features/receive/views/ReceiveHomeView.tsx index ed55262..05f788a 100644 --- a/src/features/receive/views/ReceiveHomeView.tsx +++ b/src/features/receive/views/ReceiveHomeView.tsx @@ -6,8 +6,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { useEffect, useState } from 'react'; -import { StyleSheet } from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { Button, Screen, TextInput } from '@/components/ui'; @@ -24,7 +22,6 @@ import { import { useTheme } from '@/hooks/use-theme'; import { trackEvent } from '@/lib/analytics'; import { AssetSelector } from '@/features/wallet/components/AssetSelector'; -import type { SupportedAssetCode } from '@/features/wallet/constants/assets'; export const ReceiveHomeView = () => { const theme = useTheme(); diff --git a/src/features/wallet/schemas/paymentTx.ts b/src/features/wallet/schemas/paymentTx.ts new file mode 100644 index 0000000..8891864 --- /dev/null +++ b/src/features/wallet/schemas/paymentTx.ts @@ -0,0 +1,124 @@ +/** + * CLI-039 — Payment transaction input schema and amount validation. + * + * Validates Stellar payment parameters before TransactionBuilder constructs ops. + * Amount parsing uses bigint stroops — no JavaScript Number for monetary math. + */ +import { z } from 'zod'; + +import { + STELLAR_ASSETS, + type SupportedAssetCode, + isSupportedAssetCode, +} from '@/features/wallet/constants/assets'; + +/** Stellar StrKey public key (G + 55 base32 chars) — aligned with paymentRequest schema. */ +export const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; + +/** Default transaction timebounds window (seconds) — C09 spec. */ +export const DEFAULT_PAYMENT_TX_TIMEOUT_SECONDS = 300; + +/** Stellar wire-format precision (stroops = 10^7). */ +export const STELLAR_AMOUNT_PRECISION = 7; + +const STROOP_FACTOR = 10n ** 7n; + +const AMOUNT_STRING_REGEX = /^(\d+)(?:\.(\d+))?$/; + +export const PAYMENT_TX_ERRORS = { + invalidSource: 'Invalid source Stellar public key', + invalidDestination: 'Invalid destination Stellar public key', + unsupportedAsset: 'Unsupported asset code', + invalidAmountFormat: 'Amount must be a positive decimal string', + amountTooManyDecimals: 'Amount exceeds the maximum decimal precision for this asset', + amountZero: 'Amount must be greater than zero', + memoTooLong: 'Memo must be at most 28 characters', +} as const; + +export const paymentTxSchema = z.object({ + source: z.string().regex(STELLAR_PUBLIC_KEY_REGEX, PAYMENT_TX_ERRORS.invalidSource), + destination: z.string().regex(STELLAR_PUBLIC_KEY_REGEX, PAYMENT_TX_ERRORS.invalidDestination), + asset: z + .string() + .refine(isSupportedAssetCode, { message: PAYMENT_TX_ERRORS.unsupportedAsset }), + amount: z.string().min(1, PAYMENT_TX_ERRORS.invalidAmountFormat), + memo: z + .string() + .max(28, PAYMENT_TX_ERRORS.memoTooLong) + .optional(), +}); + +export type PaymentTxParams = z.infer; + +export class PaymentTxValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'PaymentTxValidationError'; + } +} + +export interface ParsedPaymentAmount { + /** Canonical decimal string with exactly 7 fractional digits for Stellar SDK. */ + stellarAmount: string; + /** Integer stroops (1 XLM = 10^7 stroops). */ + stroops: bigint; +} + +export function parsePaymentTxParams(input: unknown): PaymentTxParams { + const result = paymentTxSchema.safeParse(input); + if (!result.success) { + const message = result.error.issues.map((issue) => issue.message).join('; '); + throw new PaymentTxValidationError(message); + } + return result.data; +} + +/** + * Validates and converts a decimal amount string to stroops without floating-point math. + * Rejects zero, excess fractional digits, and malformed strings. + */ +export function parsePaymentAmount( + amount: string, + asset: SupportedAssetCode +): ParsedPaymentAmount { + const trimmed = amount.trim(); + const match = trimmed.match(AMOUNT_STRING_REGEX); + + if (!match) { + throw new PaymentTxValidationError(PAYMENT_TX_ERRORS.invalidAmountFormat); + } + + const wholePart = match[1]; + const fractionPart = match[2] ?? ''; + const maxDecimals = STELLAR_ASSETS[asset].decimals; + + if (fractionPart.length > maxDecimals) { + throw new PaymentTxValidationError(PAYMENT_TX_ERRORS.amountTooManyDecimals); + } + + if (fractionPart.length > STELLAR_AMOUNT_PRECISION) { + throw new PaymentTxValidationError(PAYMENT_TX_ERRORS.amountTooManyDecimals); + } + + const paddedFraction = fractionPart.padEnd(STELLAR_AMOUNT_PRECISION, '0'); + const stroops = BigInt(`${wholePart}${paddedFraction}`); + + if (stroops <= 0n) { + throw new PaymentTxValidationError(PAYMENT_TX_ERRORS.amountZero); + } + + return { + stellarAmount: `${wholePart}.${paddedFraction}`, + stroops, + }; +} + +export function stroopsToStellarAmount(stroops: bigint): string { + const negative = stroops < 0n; + const absolute = negative ? -stroops : stroops; + const whole = absolute / STROOP_FACTOR; + const fraction = absolute % STROOP_FACTOR; + const fractionStr = fraction.toString().padStart(STELLAR_AMOUNT_PRECISION, '0'); + const formatted = `${whole}.${fractionStr}`; + return negative ? `-${formatted}` : formatted; +} diff --git a/src/features/wallet/services/AccountService.test.ts b/src/features/wallet/services/AccountService.test.ts index dd8e6f3..31b5423 100644 --- a/src/features/wallet/services/AccountService.test.ts +++ b/src/features/wallet/services/AccountService.test.ts @@ -2,6 +2,7 @@ import { Horizon, NotFoundError } from '@stellar/stellar-sdk'; import { SecureKeyStore } from '@/lib/SecureKeyStore'; import { env } from '@/lib/env'; +import { mapHorizonError, WalletErrorCode } from './walletErrors'; import { AccountService } from './AccountService'; jest.mock('@stellar/stellar-sdk', () => { @@ -95,6 +96,23 @@ describe('AccountService.accountExistsOnNetwork', () => { server.loadAccount.mockRejectedValueOnce(new Error('network down')); await expect(AccountService.accountExistsOnNetwork('GPUB')).rejects.toThrow('network down'); }); + + it('allows callers to map rethrown Horizon failures via walletErrors', async () => { + const networkError = new TypeError('Network request failed'); + server.loadAccount.mockRejectedValueOnce(networkError); + + await expect(AccountService.accountExistsOnNetwork('GPUB')).rejects.toThrow('Network request failed'); + expect(mapHorizonError(networkError).code).toBe(WalletErrorCode.NETWORK_ERROR); + }); + + it('maps NotFoundError separately from network failures at the wallet boundary', () => { + expect(mapHorizonError(new NotFoundError('missing', {})).code).toBe( + WalletErrorCode.ACCOUNT_NOT_FOUND + ); + expect(mapHorizonError(new TypeError('Network request failed')).code).toBe( + WalletErrorCode.NETWORK_ERROR + ); + }); }); describe('AccountService.fundTestnetAccount', () => { @@ -126,6 +144,35 @@ describe('AccountService.fundTestnetAccount', () => { expect(result).toEqual({ outcome: 'already_funded' }); }); + it('treats op_already_exists result codes as already funded', async () => { + const error = Object.assign(new Error('Bad Request'), { + response: { + data: { + extras: { + result_codes: { + operations: ['op_already_exists'], + }, + }, + }, + }, + }); + friendbotCall.mockRejectedValueOnce(error); + + const result = await AccountService.fundTestnetAccount('GPUB'); + + expect(result).toEqual({ outcome: 'already_funded' }); + }); + + it('maps friendbot network failures to NETWORK_ERROR via walletErrors', async () => { + const networkError = new TypeError('Network request failed'); + friendbotCall.mockRejectedValueOnce(networkError); + + const result = await AccountService.fundTestnetAccount('GPUB'); + + expect(result.outcome).toBe('error'); + expect(mapHorizonError(networkError).code).toBe(WalletErrorCode.NETWORK_ERROR); + }); + it('surfaces a user-safe error on a genuine funding failure', async () => { friendbotCall.mockRejectedValueOnce(new Error('network down')); diff --git a/src/features/wallet/services/AccountService.ts b/src/features/wallet/services/AccountService.ts index 618c6d9..6a3f5fd 100644 --- a/src/features/wallet/services/AccountService.ts +++ b/src/features/wallet/services/AccountService.ts @@ -1,13 +1,9 @@ -import { Horizon, Keypair, NotFoundError } from '@stellar/stellar-sdk'; +import { Keypair, NotFoundError } from '@stellar/stellar-sdk'; import { env } from '@/lib/env'; import { SecureKeyStore } from '@/lib/SecureKeyStore'; import { SECURE_KEYS } from '@/lib/SecureKeyStore.types'; -import { ensureStellarPolyfills } from './stellarPolyfills'; - -ensureStellarPolyfills(); - -const server = new Horizon.Server(env.horizonUrl); +import { stellarHorizonClient } from './StellarHorizonClient'; export interface WalletKeypair { publicKey: string; @@ -53,7 +49,7 @@ export const AccountService = { async accountExistsOnNetwork(publicKey: string): Promise { try { - await server.loadAccount(publicKey); + await stellarHorizonClient.loadAccount(publicKey); return true; } catch (error) { if (error instanceof NotFoundError) { @@ -72,7 +68,7 @@ export const AccountService = { } try { - await server.friendbot(publicKey).call(); + await stellarHorizonClient.fundWithFriendbot(publicKey); return { outcome: 'funded' }; } catch (error) { if (isAccountAlreadyFundedError(error)) { diff --git a/src/features/wallet/services/BalanceService.test.ts b/src/features/wallet/services/BalanceService.test.ts index 9840b49..0335661 100644 --- a/src/features/wallet/services/BalanceService.test.ts +++ b/src/features/wallet/services/BalanceService.test.ts @@ -1,6 +1,6 @@ import { Horizon } from '@stellar/stellar-sdk'; -import { fetchBalances, parseBalances } from './BalanceService'; +import { fetchBalances, getMinimumBalanceStroops, parseBalances, parseNativeBalanceStroops, parseUsdcBalanceStroops } from './BalanceService'; jest.mock('@stellar/stellar-sdk', () => { const horizonServerInstance = { @@ -73,6 +73,53 @@ describe('parseBalances', () => { }); }); +describe('parseNativeBalanceStroops', () => { + it('converts Horizon native balance strings to stroops without floating point', () => { + const stroops = parseNativeBalanceStroops({ + balances: [{ asset_type: 'native', balance: '123.4567890' } as never], + }); + + expect(stroops).toBe(1234567890n); + expect(typeof stroops).toBe('bigint'); + }); + + it('returns 0n when no native line is present', () => { + expect(parseNativeBalanceStroops({ balances: [] })).toBe(0n); + }); +}); + +describe('parseUsdcBalanceStroops', () => { + it('converts matching USDC trustline balances to stroops', () => { + const stroops = parseUsdcBalanceStroops({ + balances: [ + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: USDC_ISSUER, + balance: '10.5000001', + } as never, + ], + }); + + expect(stroops).toBe(105000001n); + }); + + it('returns 0n when no configured USDC trustline exists', () => { + expect(parseUsdcBalanceStroops({ balances: [] })).toBe(0n); + }); +}); + +describe('getMinimumBalanceStroops', () => { + it('returns 1 XLM (10_000_000 stroops) for subentry_count 0', () => { + expect(getMinimumBalanceStroops(0)).toBe(10_000_000n); + }); + + it('scales reserve with subentry_count using (2 + n) * base reserve', () => { + expect(getMinimumBalanceStroops(3)).toBe(25_000_000n); + expect(getMinimumBalanceStroops(10)).toBe(60_000_000n); + }); +}); + describe('fetchBalances', () => { beforeEach(() => { server.loadAccount.mockReset(); diff --git a/src/features/wallet/services/BalanceService.ts b/src/features/wallet/services/BalanceService.ts index 6aa7192..93ba18e 100644 --- a/src/features/wallet/services/BalanceService.ts +++ b/src/features/wallet/services/BalanceService.ts @@ -1,12 +1,13 @@ import { Horizon } from '@stellar/stellar-sdk'; -import { env } from '@/lib/env'; import { STELLAR_ASSETS } from '@/features/wallet/constants/assets'; -import { ensureStellarPolyfills } from './stellarPolyfills'; +import { + getMinimumBalanceStroops, + horizonBalanceToStroops, +} from '@/features/wallet/utils/stellarReserve'; +import { stellarHorizonClient } from './StellarHorizonClient'; -ensureStellarPolyfills(); - -const server = new Horizon.Server(env.horizonUrl); +export { getMinimumBalanceStroops }; export interface WalletBalances { xlm: string; @@ -16,6 +17,31 @@ export interface WalletBalances { type AccountBalances = Pick; +type AccountWithNativeBalance = Pick; + +export function parseNativeBalanceStroops(account: AccountWithNativeBalance): bigint { + const nativeLine = account.balances.find((balance) => balance.asset_type === 'native'); + if (!nativeLine) { + return 0n; + } + + return horizonBalanceToStroops(nativeLine.balance); +} + +export function parseUsdcBalanceStroops(account: AccountBalances): bigint { + for (const line of account.balances) { + if ( + 'asset_code' in line && + line.asset_code === STELLAR_ASSETS.USDC.code && + line.asset_issuer === STELLAR_ASSETS.USDC.issuer + ) { + return horizonBalanceToStroops(line.balance); + } + } + + return 0n; +} + export function parseBalances(account: AccountBalances): WalletBalances { let xlm = '0'; let usdc: string | null = null; @@ -41,11 +67,14 @@ export function parseBalances(account: AccountBalances): WalletBalances { } export async function fetchBalances(publicKey: string): Promise { - const account = await server.loadAccount(publicKey); + const account = await stellarHorizonClient.loadAccount(publicKey); return parseBalances(account); } export const BalanceService = { parseBalances, + parseNativeBalanceStroops, + parseUsdcBalanceStroops, + getMinimumBalanceStroops, fetchBalances, }; diff --git a/src/features/wallet/services/FeeService.test.ts b/src/features/wallet/services/FeeService.test.ts new file mode 100644 index 0000000..c364c87 --- /dev/null +++ b/src/features/wallet/services/FeeService.test.ts @@ -0,0 +1,228 @@ +import { NotFoundError } from '@stellar/stellar-sdk'; + +import { PaymentTxValidationError } from '@/features/wallet/schemas/paymentTx'; +import { canAffordPayment, estimateFee } from './FeeService'; + +jest.mock('@stellar/stellar-sdk', () => { + class MockNotFoundError extends Error { + constructor(message?: string) { + super(message ?? 'Not Found'); + this.name = 'NotFoundError'; + } + } + + const horizonServerInstance = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + }; + + return { + BASE_FEE: '100', + NotFoundError: MockNotFoundError, + Horizon: { + Server: jest.fn(() => horizonServerInstance), + }, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + Asset: jest.fn(), + Keypair: { fromSecret: jest.fn() }, + Operation: { changeTrust: jest.fn() }, + TransactionBuilder: jest.fn(), + }; +}); + +jest.mock('@/lib/SecureKeyStore', () => ({ + SecureKeyStore: { + get: jest.fn(), + set: jest.fn(), + delete: jest.fn(), + }, +})); + +const USDC_ISSUER = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; +const PUBLIC_KEY = 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO'; + +function createMockHorizonClient() { + return { loadAccount: jest.fn() }; +} + +function nativeAccount(balance: string, subentryCount = 0) { + return { + balances: [{ asset_type: 'native', balance }], + subentry_count: subentryCount, + }; +} + +function usdcAccount(nativeBalance: string, usdcBalance: string, subentryCount = 1) { + return { + balances: [ + { asset_type: 'native', balance: nativeBalance }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: USDC_ISSUER, + balance: usdcBalance, + }, + ], + subentry_count: subentryCount, + }; +} + +describe('estimateFee', () => { + it('returns 100 stroops and 0.0000100 XLM for a single base-fee operation', () => { + const fee = estimateFee(); + + expect(fee.feeStroops).toBe(100n); + expect(fee.feeXlm).toBe('0.0000100'); + }); +}); + +describe('canAffordPayment', () => { + it('returns true for XLM when balance covers amount, fee, and minimum reserve', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('5.0000000', 0)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result).toEqual({ + canAfford: true, + estimatedFee: { feeStroops: 100n, feeXlm: '0.0000100' }, + }); + }); + + it('returns false for XLM when balance is below amount + fee + reserve', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('0.5000000', 0)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_balance'); + }); + + it('returns false when XLM covers the amount but not fee and reserve', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('1.5000100', 0)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_xlm_for_fee_and_reserve'); + }); + + it('returns true for USDC when USDC and XLM for fee plus reserve are sufficient', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(usdcAccount('2.0000100', '10.0000000')); + + const result = await canAffordPayment(PUBLIC_KEY, '5', 'USDC', { horizonClient }); + + expect(result.canAfford).toBe(true); + }); + + it('returns false when USDC balance is insufficient', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(usdcAccount('2.0000100', '1.0000000')); + + const result = await canAffordPayment(PUBLIC_KEY, '5', 'USDC', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_balance'); + }); + + it('returns false when USDC is sufficient but XLM cannot cover fee and reserve', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(usdcAccount('1.0000000', '10.0000000')); + + const result = await canAffordPayment(PUBLIC_KEY, '5', 'USDC', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_xlm_for_fee_and_reserve'); + }); + + it('returns false for USDC when the payer has no USDC trustline', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('5.0000000', 0)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'USDC', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('no_usdc_trustline'); + }); + + it('throws PaymentTxValidationError for zero amount', async () => { + const horizonClient = createMockHorizonClient(); + + await expect( + canAffordPayment(PUBLIC_KEY, '0', 'XLM', { horizonClient }) + ).rejects.toThrow(PaymentTxValidationError); + + expect(horizonClient.loadAccount).not.toHaveBeenCalled(); + }); + + it('throws PaymentTxValidationError for negative amount', async () => { + const horizonClient = createMockHorizonClient(); + + await expect( + canAffordPayment(PUBLIC_KEY, '-1', 'XLM', { horizonClient }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('throws PaymentTxValidationError for too many decimal places', async () => { + const horizonClient = createMockHorizonClient(); + + await expect( + canAffordPayment(PUBLIC_KEY, '1.12345678', 'XLM', { horizonClient }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('returns false for dust payments that would leave the account below minimum reserve', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('2.0000100', 0)); + + const result = await canAffordPayment(PUBLIC_KEY, '1.0000100', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_xlm_for_fee_and_reserve'); + }); + + it('returns account_not_found when Horizon reports a missing account', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockRejectedValueOnce(new NotFoundError('missing', {})); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('account_not_found'); + }); + + it('returns network_error for other Horizon failures', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockRejectedValueOnce(new Error('timeout')); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('network_error'); + }); + + it('requires more XLM reserve when subentry_count is high', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('7.0000099', 10)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(false); + expect(result.reason).toBe('insufficient_xlm_for_fee_and_reserve'); + }); + + it('allows payment at the edge when subentry_count reserve is exactly met', async () => { + const horizonClient = createMockHorizonClient(); + horizonClient.loadAccount.mockResolvedValueOnce(nativeAccount('7.0000100', 10)); + + const result = await canAffordPayment(PUBLIC_KEY, '1', 'XLM', { horizonClient }); + + expect(result.canAfford).toBe(true); + }); +}); diff --git a/src/features/wallet/services/FeeService.ts b/src/features/wallet/services/FeeService.ts new file mode 100644 index 0000000..21a7342 --- /dev/null +++ b/src/features/wallet/services/FeeService.ts @@ -0,0 +1,147 @@ +/** + * CLI-040 — Fee estimation and payment affordability checks. + * + * Uses stroops/bigint for monetary math. Does not submit transactions. + */ +import { BASE_FEE, NotFoundError } from '@stellar/stellar-sdk'; + +import type { SupportedAssetCode } from '@/features/wallet/constants/assets'; +import { parsePaymentAmount, stroopsToStellarAmount } from '@/features/wallet/schemas/paymentTx'; +import { + getMinimumBalanceStroops, + parseNativeBalanceStroops, + parseUsdcBalanceStroops, +} from './BalanceService'; +import { hasUsdcTrustline } from './TrustlineService'; +import { + stellarHorizonClient, + type StellarHorizonClient, +} from './StellarHorizonClient'; + +export interface FeeEstimate { + feeStroops: bigint; + feeXlm: string; +} + +export type AffordabilityReason = + | 'insufficient_balance' + | 'insufficient_xlm_for_fee_and_reserve' + | 'no_usdc_trustline' + | 'account_not_found' + | 'network_error'; + +export interface AffordabilityResult { + canAfford: boolean; + reason?: AffordabilityReason; + estimatedFee?: FeeEstimate; +} + +export interface CanAffordPaymentDeps { + horizonClient?: Pick; +} + +/** MVP: single-operation payment fee from Stellar SDK base fee (100 stroops). */ +export function estimateFee(): FeeEstimate { + const feeStroops = BigInt(BASE_FEE); + return { + feeStroops, + feeXlm: stroopsToStellarAmount(feeStroops), + }; +} + +/** + * Determines whether an account can afford a payment including fee and minimum reserve. + * + * USDC payments require an existing USDC trustline on the payer account — a payer cannot + * send an asset for which they hold no trustline (same policy as receive/trustline flows). + */ +export async function canAffordPayment( + publicKey: string, + amount: string, + asset: SupportedAssetCode, + deps: CanAffordPaymentDeps = {} +): Promise { + const { stroops: amountStroops } = parsePaymentAmount(amount, asset); + const estimatedFee = estimateFee(); + const horizonClient = deps.horizonClient ?? stellarHorizonClient; + + let account; + try { + account = await horizonClient.loadAccount(publicKey); + } catch (error) { + if (error instanceof NotFoundError) { + return { + canAfford: false, + reason: 'account_not_found', + estimatedFee, + }; + } + + return { + canAfford: false, + reason: 'network_error', + estimatedFee, + }; + } + + const nativeStroops = parseNativeBalanceStroops(account); + const subentryCount = account.subentry_count ?? 0; + const minBalanceStroops = getMinimumBalanceStroops(subentryCount); + const xlmRequiredAfterPayment = estimatedFee.feeStroops + minBalanceStroops; + + if (asset === 'XLM') { + const totalRequired = amountStroops + xlmRequiredAfterPayment; + + if (nativeStroops < totalRequired) { + if (nativeStroops >= amountStroops) { + return { + canAfford: false, + reason: 'insufficient_xlm_for_fee_and_reserve', + estimatedFee, + }; + } + + return { + canAfford: false, + reason: 'insufficient_balance', + estimatedFee, + }; + } + + return { canAfford: true, estimatedFee }; + } + + // USDC — payer must hold a USDC trustline to send USDC. + if (!hasUsdcTrustline(account)) { + return { + canAfford: false, + reason: 'no_usdc_trustline', + estimatedFee, + }; + } + + const usdcStroops = parseUsdcBalanceStroops(account); + + if (usdcStroops < amountStroops) { + return { + canAfford: false, + reason: 'insufficient_balance', + estimatedFee, + }; + } + + if (nativeStroops < xlmRequiredAfterPayment) { + return { + canAfford: false, + reason: 'insufficient_xlm_for_fee_and_reserve', + estimatedFee, + }; + } + + return { canAfford: true, estimatedFee }; +} + +export const FeeService = { + estimateFee, + canAffordPayment, +}; diff --git a/src/features/wallet/services/StellarHorizonClient.test.ts b/src/features/wallet/services/StellarHorizonClient.test.ts new file mode 100644 index 0000000..c5a193e --- /dev/null +++ b/src/features/wallet/services/StellarHorizonClient.test.ts @@ -0,0 +1,80 @@ +import { Horizon } from '@stellar/stellar-sdk'; + +import { env } from '@/lib/env'; +import { + createStellarHorizonClient, + stellarHorizonClient, +} from './StellarHorizonClient'; + +jest.mock('@stellar/stellar-sdk', () => { + const horizonServerInstance = { + loadAccount: jest.fn(), + submitTransaction: jest.fn(), + friendbot: jest.fn(() => ({ call: jest.fn() })), + }; + + return { + Horizon: { + Server: jest.fn(() => horizonServerInstance), + }, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + }; +}); + +const server = new Horizon.Server('https://horizon-testnet.stellar.org') as unknown as { + loadAccount: jest.Mock; + submitTransaction: jest.Mock; + friendbot: jest.Mock; +}; + +describe('StellarHorizonClient', () => { + beforeEach(() => { + server.loadAccount.mockReset(); + server.submitTransaction.mockReset(); + server.friendbot.mockReset(); + }); + + it('stores the configured Horizon URL', () => { + const client = createStellarHorizonClient('https://horizon-testnet.stellar.org'); + expect(client.horizonUrl).toBe('https://horizon-testnet.stellar.org'); + }); + + it('delegates loadAccount to the underlying Horizon server', async () => { + server.loadAccount.mockResolvedValueOnce({ id: 'GPUB' }); + + const client = createStellarHorizonClient('https://horizon-testnet.stellar.org'); + const account = await client.loadAccount('GPUB'); + + expect(server.loadAccount).toHaveBeenCalledWith('GPUB'); + expect(account).toEqual({ id: 'GPUB' }); + }); + + it('delegates submitTransaction to the underlying Horizon server', async () => { + server.submitTransaction.mockResolvedValueOnce({ hash: 'abc' }); + + const client = createStellarHorizonClient('https://horizon-testnet.stellar.org'); + const tx = { signed: true } as never; + const response = await client.submitTransaction(tx); + + expect(server.submitTransaction).toHaveBeenCalledWith(tx); + expect(response).toEqual({ hash: 'abc' }); + }); + + it('delegates fundWithFriendbot to the underlying Horizon server', async () => { + const friendbotCall = jest.fn().mockResolvedValueOnce({}); + server.friendbot.mockReturnValueOnce({ call: friendbotCall }); + + const client = createStellarHorizonClient('https://horizon-testnet.stellar.org'); + await client.fundWithFriendbot('GPUB'); + + expect(server.friendbot).toHaveBeenCalledWith('GPUB'); + expect(friendbotCall).toHaveBeenCalled(); + }); + + it('exposes a shared singleton configured from env', () => { + expect(stellarHorizonClient.horizonUrl).toBe(env.horizonUrl); + }); +}); diff --git a/src/features/wallet/services/StellarHorizonClient.ts b/src/features/wallet/services/StellarHorizonClient.ts new file mode 100644 index 0000000..7cf3936 --- /dev/null +++ b/src/features/wallet/services/StellarHorizonClient.ts @@ -0,0 +1,43 @@ +import { Horizon } from '@stellar/stellar-sdk'; +import type { Transaction } from '@stellar/stellar-sdk'; + +import { env } from '@/lib/env'; +import { ensureStellarPolyfills } from './stellarPolyfills'; + +export type SubmitTransactionResponse = Horizon.HorizonApi.SubmitTransactionResponse; + +/** + * Typed wrapper around a single Horizon.Server instance for wallet services. + * Centralizes URL configuration and keeps SDK construction mock-friendly in tests. + */ +export class StellarHorizonClient { + readonly horizonUrl: string; + private readonly server: Horizon.Server; + + constructor(horizonUrl: string) { + ensureStellarPolyfills(); + this.horizonUrl = horizonUrl; + this.server = new Horizon.Server(horizonUrl); + } + + loadAccount(publicKey: string): Promise { + return this.server.loadAccount(publicKey); + } + + submitTransaction(transaction: Transaction): Promise { + return this.server.submitTransaction(transaction); + } + + fundWithFriendbot(publicKey: string): Promise { + return this.server.friendbot(publicKey).call(); + } +} + +export function createStellarHorizonClient( + horizonUrl: string = env.horizonUrl +): StellarHorizonClient { + return new StellarHorizonClient(horizonUrl); +} + +/** Shared Horizon client configured from typed env (testnet/mainnet). */ +export const stellarHorizonClient = createStellarHorizonClient(); diff --git a/src/features/wallet/services/TransactionBuilder.ts b/src/features/wallet/services/TransactionBuilder.ts new file mode 100644 index 0000000..d1deece --- /dev/null +++ b/src/features/wallet/services/TransactionBuilder.ts @@ -0,0 +1,87 @@ +/** + * CLI-039 — Payment transaction builder (unsigned). + * + * Builds Stellar payment operations for XLM and USDC. Does not sign or submit. + */ +import { + Asset, + BASE_FEE, + Memo, + Operation, + TransactionBuilder as StellarTransactionBuilder, +} from '@stellar/stellar-sdk'; +import type { Transaction } from '@stellar/stellar-sdk'; + +import { STELLAR_ASSETS, type SupportedAssetCode } from '@/features/wallet/constants/assets'; +import { + DEFAULT_PAYMENT_TX_TIMEOUT_SECONDS, + parsePaymentAmount, + parsePaymentTxParams, + type PaymentTxParams, +} from '@/features/wallet/schemas/paymentTx'; +import { env } from '@/lib/env'; +import { + stellarHorizonClient, + type StellarHorizonClient, +} from './StellarHorizonClient'; + +export class PaymentTxBuildError extends Error { + constructor(message: string) { + super(message); + this.name = 'PaymentTxBuildError'; + } +} + +export interface BuildPaymentTxDeps { + horizonClient?: Pick; + timeoutSeconds?: number; + networkPassphrase?: string; +} + +function toSdkAsset(assetCode: SupportedAssetCode): Asset { + if (assetCode === 'XLM') { + return Asset.native(); + } + + const usdc = STELLAR_ASSETS.USDC; + if (!usdc.issuer) { + throw new PaymentTxBuildError('USDC issuer is not configured.'); + } + + return new Asset(usdc.code, usdc.issuer); +} + +export async function buildPaymentTx( + input: PaymentTxParams, + deps: BuildPaymentTxDeps = {} +): Promise { + const params = parsePaymentTxParams(input); + const assetCode = params.asset as SupportedAssetCode; + const { stellarAmount } = parsePaymentAmount(params.amount, assetCode); + + const horizonClient = deps.horizonClient ?? stellarHorizonClient; + + let sourceAccount; + try { + sourceAccount = await horizonClient.loadAccount(params.source); + } catch { + throw new PaymentTxBuildError('No se pudo cargar la cuenta origen.'); + } + + const builder = new StellarTransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase: deps.networkPassphrase ?? env.networkPassphrase, + }).addOperation( + Operation.payment({ + destination: params.destination, + asset: toSdkAsset(assetCode), + amount: stellarAmount, + }) + ); + + if (params.memo) { + builder.addMemo(Memo.text(params.memo)); + } + + return builder.setTimeout(deps.timeoutSeconds ?? DEFAULT_PAYMENT_TX_TIMEOUT_SECONDS).build(); +} diff --git a/src/features/wallet/services/TrustlineService.test.ts b/src/features/wallet/services/TrustlineService.test.ts index a2edb65..d40ff82 100644 --- a/src/features/wallet/services/TrustlineService.test.ts +++ b/src/features/wallet/services/TrustlineService.test.ts @@ -5,9 +5,17 @@ import { ensureUsdcTrustline, hasSufficientReserveForTrustline, hasUsdcTrustline, + TrustlineService, } from './TrustlineService'; jest.mock('@stellar/stellar-sdk', () => { + class MockNotFoundError extends Error { + constructor(message?: string) { + super(message ?? 'Not Found'); + this.name = 'NotFoundError'; + } + } + class MockAsset { code: string; issuer?: string; @@ -49,6 +57,7 @@ jest.mock('@stellar/stellar-sdk', () => { changeTrust: jest.fn().mockReturnValue({}), }, TransactionBuilder: MockTransactionBuilder, + NotFoundError: MockNotFoundError, Networks: { PUBLIC: 'Public Global Stellar Network ; September 2015', TESTNET: 'Test SDF Network ; September 2015', @@ -177,3 +186,50 @@ describe('ensureUsdcTrustline', () => { expect(result.status).toBe('error'); }); }); + +describe('TrustlineService.checkUsdcTrustline', () => { + const PUBLIC_KEY = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXY'; + + beforeEach(() => { + server.loadAccount.mockReset(); + }); + + it('returns hasLine: true when the account has a matching USDC trustline', async () => { + server.loadAccount.mockResolvedValueOnce({ + balances: [ + { asset_type: 'native', balance: '100' }, + { + asset_type: 'credit_alphanum4', + asset_code: 'USDC', + asset_issuer: USDC_ISSUER, + balance: '50', + }, + ], + }); + + const service = new TrustlineService(); + const result = await service.checkUsdcTrustline(PUBLIC_KEY); + + expect(result).toEqual({ hasLine: true, sufficientReserve: true }); + }); + + it('returns hasLine: false when no USDC trustline entry exists', async () => { + server.loadAccount.mockResolvedValueOnce({ + balances: [{ asset_type: 'native', balance: '100' }], + }); + + const service = new TrustlineService(); + const result = await service.checkUsdcTrustline(PUBLIC_KEY); + + expect(result).toEqual({ hasLine: false, sufficientReserve: true }); + }); + + it('returns hasLine: false when the account cannot be found', async () => { + server.loadAccount.mockRejectedValueOnce(new Error('Not Found')); + + const service = new TrustlineService(); + const result = await service.checkUsdcTrustline(PUBLIC_KEY); + + expect(result).toEqual({ hasLine: false, sufficientReserve: false }); + }); +}); diff --git a/src/features/wallet/services/TrustlineService.ts b/src/features/wallet/services/TrustlineService.ts index f626fa8..4ae0183 100644 --- a/src/features/wallet/services/TrustlineService.ts +++ b/src/features/wallet/services/TrustlineService.ts @@ -1,39 +1,136 @@ /** - * USDC trustline checks (CLI-070). + * USDC trustline setup (CLI-037) and read-only checks (CLI-070). * - * The receive flow must confirm the receiver already has a USDC trustline - * before broadcasting a USDC payment request over NFC — a payer cannot send - * an asset the receiver has no line for. + * - ensureUsdcTrustline: wallet setup — idempotent changeTrust when missing + * - checkUsdcTrustline: receive flow — read-only line presence check * * @see docs/receive-flow.md — error matrix (trustline_missing) */ -import { Horizon } from '@stellar/stellar-sdk'; +import { + Asset, + BASE_FEE, + Horizon, + Keypair, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; import { env } from '@/lib/env'; +import { SecureKeyStore } from '@/lib/SecureKeyStore'; +import { SECURE_KEYS } from '@/lib/SecureKeyStore.types'; +import { + getMinimumBalanceStroopsForNewTrustline, + getMinimumBalanceXlmForNewTrustline, +} from '@/features/wallet/utils/stellarReserve'; +import { parseNativeBalanceStroops } from './BalanceService'; +import { createWalletError, mapHorizonError, WalletErrorCode } from './walletErrors'; +import { stellarHorizonClient } from './StellarHorizonClient'; +type AccountBalances = Pick; export interface TrustlineCheckResult { hasLine: boolean; sufficientReserve: boolean; } +export type EnsureUsdcTrustlineStatus = + | 'already_trusted' + | 'created' + | 'insufficient_reserve' + | 'error'; + +export interface EnsureUsdcTrustlineResult { + status: EnsureUsdcTrustlineStatus; + message?: string; +} + +export function hasUsdcTrustline(account: AccountBalances): boolean { + return account.balances.some( + (balance) => + 'asset_code' in balance && + balance.asset_code === 'USDC' && + 'asset_issuer' in balance && + balance.asset_issuer === env.usdcIssuer + ); +} + +/** + * Returns whether native XLM balance can cover the reserve for one additional trustline. + * Uses +1 subentry for the new trustline and the trustline-only buffer (see stellarReserve.ts). + */ +export function hasSufficientReserveForTrustline( + xlmBalance: number, + subentryCount: number +): boolean { + return xlmBalance >= getMinimumBalanceXlmForNewTrustline(subentryCount); +} +export async function ensureUsdcTrustline(publicKey: string): Promise { + try { + const account = await stellarHorizonClient.loadAccount(publicKey); + + if (hasUsdcTrustline(account)) { + return { status: 'already_trusted' }; + } + + const nativeStroops = parseNativeBalanceStroops(account); + const subentryCount = account.subentry_count ?? 0; + + if (nativeStroops < getMinimumBalanceStroopsForNewTrustline(subentryCount)) { + return { + status: 'insufficient_reserve', + message: + 'Saldo insuficiente para habilitar USDC. Deposita más XLM para cubrir la reserva mínima.', + }; + } + + const secretKey = await SecureKeyStore.get(SECURE_KEYS.WALLET_STELLAR_SECRET_KEY); + if (!secretKey) { + return { + status: 'error', + message: createWalletError(WalletErrorCode.WALLET_KEY_MISSING).message, + }; + } + + const sourceKeypair = Keypair.fromSecret(secretKey); + const usdcAsset = new Asset('USDC', env.usdcIssuer); + + const transaction = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: env.networkPassphrase, + }) + .addOperation( + Operation.changeTrust({ + asset: usdcAsset, + }) + ) + .setTimeout(300) + .build(); + + transaction.sign(sourceKeypair); + await stellarHorizonClient.submitTransaction(transaction); + + return { status: 'created' }; + } catch (error) { + return { + status: 'error', + message: mapHorizonError(error).message, + }; + } +} + export class TrustlineService { + /** + * Read-only USDC trustline check for receive/NFC flows. + * Does not submit transactions or read secret keys. + */ async checkUsdcTrustline(publicKey: string): Promise { try { - const server = new Horizon.Server(env.horizonUrl); - const account = await server.loadAccount(publicKey); - - const hasLine = account.balances.some( - (balance) => - 'asset_code' in balance && - balance.asset_code === 'USDC' && - 'asset_issuer' in balance && - balance.asset_issuer === env.usdcIssuer - ); + const account = await stellarHorizonClient.loadAccount(publicKey); + const hasLine = hasUsdcTrustline(account); return { hasLine, - // Reserve sizing (base reserve + subentry count) is a future concern — MVP - // assumes a trustline that exists has sufficient reserve backing it. + // MVP: when the account loads, assume reserve is sufficient for the read path. + // ensureUsdcTrustline performs the strict reserve check before creating a line. sufficientReserve: true, }; } catch { diff --git a/src/features/wallet/services/__tests__/TransactionBuilder.test.ts b/src/features/wallet/services/__tests__/TransactionBuilder.test.ts new file mode 100644 index 0000000..ab749a0 --- /dev/null +++ b/src/features/wallet/services/__tests__/TransactionBuilder.test.ts @@ -0,0 +1,428 @@ +import { NotFoundError, Operation } from '@stellar/stellar-sdk'; + +import { + DEFAULT_PAYMENT_TX_TIMEOUT_SECONDS, + PAYMENT_TX_ERRORS, + PaymentTxValidationError, + parsePaymentAmount, + stroopsToStellarAmount, +} from '@/features/wallet/schemas/paymentTx'; +import { STELLAR_ASSETS } from '@/features/wallet/constants/assets'; +import { mapHorizonError, WalletErrorCode } from '../walletErrors'; +import { buildPaymentTx, PaymentTxBuildError } from '../TransactionBuilder'; + +const mockLoadAccount = jest.fn(); + +jest.mock('../StellarHorizonClient', () => ({ + stellarHorizonClient: { + loadAccount: (...args: unknown[]) => mockLoadAccount(...args), + }, +})); + +const paymentCalls: Array> = []; +const setTimeoutCalls: number[] = []; +const builderInstances: unknown[] = []; + +jest.mock('@stellar/stellar-sdk', () => { + class MockAsset { + code: string; + issuer?: string; + isNative?: boolean; + + constructor(code: string, issuer?: string) { + this.code = code; + this.issuer = issuer; + this.isNative = code === 'native'; + } + + static native() { + return new MockAsset('native'); + } + } + + class MockTransactionBuilder { + sourceAccount: unknown; + options: unknown; + private memo: unknown; + private operations: unknown[] = []; + + constructor(sourceAccount: unknown, options: unknown) { + this.sourceAccount = sourceAccount; + this.options = options; + builderInstances.push(this); + } + + addOperation(operation: unknown) { + this.operations.push(operation); + return this; + } + + addMemo(memo: unknown) { + this.memo = memo; + return this; + } + + setTimeout(seconds: number) { + setTimeoutCalls.push(seconds); + return this; + } + + build() { + return { + operations: this.operations, + memo: this.memo, + sourceAccount: this.sourceAccount, + sign: jest.fn(), + }; + } + } + + return { + Asset: MockAsset, + BASE_FEE: '100', + NotFoundError: class MockNotFoundError extends Error { + constructor(message?: string) { + super(message ?? 'Not Found'); + this.name = 'NotFoundError'; + } + }, + Memo: { + text: (value: string) => ({ type: 'text', value }), + }, + Operation: { + payment: jest.fn((params: Record) => { + paymentCalls.push(params); + return { type: 'payment', ...params }; + }), + }, + TransactionBuilder: MockTransactionBuilder, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + }; +}); + +/** Valid 56-char Stellar account ids (G + 55 base32 chars). */ +const SOURCE = 'GBRHKTZK42KXDGWYQLO3XWE4CCO76LNJV3HY33XWXX6BAHEHS5LADKKO'; +const DESTINATION = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; + +const SOURCE_ACCOUNT = { + account_id: SOURCE, + sequence: '42', + balances: [{ asset_type: 'native', balance: '100.0000000' }], +}; + +describe('buildPaymentTx', () => { + beforeEach(() => { + jest.clearAllMocks(); + paymentCalls.length = 0; + setTimeoutCalls.length = 0; + builderInstances.length = 0; + mockLoadAccount.mockReset(); + mockLoadAccount.mockResolvedValue(SOURCE_ACCOUNT); + }); + + it('builds a valid XLM payment', async () => { + const transaction = await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '10.5', + }); + + expect(mockLoadAccount).toHaveBeenCalledWith(SOURCE); + expect(paymentCalls[0]).toMatchObject({ + destination: DESTINATION, + amount: '10.5000000', + }); + expect((paymentCalls[0]?.asset as { isNative?: boolean }).isNative).toBe(true); + expect(setTimeoutCalls).toEqual([DEFAULT_PAYMENT_TX_TIMEOUT_SECONDS]); + expect(transaction.sign).toBeDefined(); + expect(transaction.sign).not.toHaveBeenCalled(); + }); + + it('builds a valid USDC payment with issuer asset', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'USDC', + amount: '25.1234567', + }); + + const asset = paymentCalls[0]?.asset as { code: string; issuer?: string }; + expect(asset.code).toBe('USDC'); + expect(asset.issuer).toBeTruthy(); + expect(paymentCalls[0]?.amount).toBe('25.1234567'); + }); + + it('converts decimal amounts to canonical 7-digit Stellar precision', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }); + + expect(paymentCalls[0]?.amount).toBe('1.0000000'); + }); + + it('rejects zero amounts', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '0', + }) + ).rejects.toThrow(PaymentTxValidationError); + + expect(mockLoadAccount).not.toHaveBeenCalled(); + }); + + it('rejects negative amounts', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '-1', + }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('rejects amounts with too many decimal places', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1.12345678', + }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('rejects sub-stroop precision such as 1.00000001', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1.00000001', + }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('rejects invalid destination keys', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: 'not-a-stellar-key', + asset: 'XLM', + amount: '1', + }) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('accepts valid Stellar destination keys', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '2', + }); + + expect(paymentCalls[0]?.destination).toBe(DESTINATION); + }); + + it('rejects unsupported assets', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'BTC', + amount: '1', + } as never) + ).rejects.toThrow(PaymentTxValidationError); + }); + + it('applies default timebounds of 300 seconds', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }); + + expect(setTimeoutCalls).toEqual([300]); + }); + + it('allows overriding timeout via deps', async () => { + await buildPaymentTx( + { + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }, + { timeoutSeconds: 120 } + ); + + expect(setTimeoutCalls).toEqual([120]); + }); + + it('rejects invalid source keys', async () => { + await expect( + buildPaymentTx({ + source: 'invalid-source-key', + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }) + ).rejects.toThrow(PaymentTxValidationError); + + expect(mockLoadAccount).not.toHaveBeenCalled(); + }); + + it('rejects memos longer than 28 characters', async () => { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + memo: 'a'.repeat(29), + }) + ).rejects.toThrow(PaymentTxValidationError); + + expect(mockLoadAccount).not.toHaveBeenCalled(); + }); + + it('throws PaymentTxBuildError when USDC issuer is not configured', async () => { + const usdcAsset = STELLAR_ASSETS.USDC as { + code: string; + issuer?: string; + displayName: string; + decimals: number; + }; + const originalIssuer = usdcAsset.issuer; + + usdcAsset.issuer = undefined; + + try { + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'USDC', + amount: '1', + }) + ).rejects.toThrow(PaymentTxBuildError); + } finally { + usdcAsset.issuer = originalIssuer; + } + }); + + it('throws when the source account cannot be loaded', async () => { + mockLoadAccount.mockRejectedValueOnce(new Error('Not Found')); + + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }) + ).rejects.toThrow(PaymentTxBuildError); + + expect(Operation.payment).not.toHaveBeenCalled(); + }); + + it('throws PaymentTxBuildError when Horizon returns NotFoundError for the source account', async () => { + mockLoadAccount.mockRejectedValueOnce(new NotFoundError('missing account', {})); + + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }) + ).rejects.toThrow(PaymentTxBuildError); + + expect(mapHorizonError(new NotFoundError('missing account', {})).code).toBe( + WalletErrorCode.ACCOUNT_NOT_FOUND + ); + }); + + it('throws PaymentTxBuildError when Horizon loadAccount fails with a network error', async () => { + mockLoadAccount.mockRejectedValueOnce(new TypeError('Network request failed')); + + await expect( + buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }) + ).rejects.toThrow(PaymentTxBuildError); + + expect(mapHorizonError(new TypeError('Network request failed')).code).toBe( + WalletErrorCode.NETWORK_ERROR + ); + }); + + it('uses the loaded account (sequence) as the transaction source', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }); + + expect(builderInstances[0]).toMatchObject({ + sourceAccount: SOURCE_ACCOUNT, + }); + expect((builderInstances[0] as { sourceAccount: { sequence: string } }).sourceAccount.sequence).toBe( + '42' + ); + }); + + it('does not perform real network calls — Horizon client is mocked', async () => { + await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + }); + + expect(mockLoadAccount).toHaveBeenCalledTimes(1); + expect(builderInstances).toHaveLength(1); + }); + + it('adds an optional text memo when provided', async () => { + const transaction = await buildPaymentTx({ + source: SOURCE, + destination: DESTINATION, + asset: 'XLM', + amount: '1', + memo: 'pago-nfc', + }); + + expect(transaction.memo).toEqual({ type: 'text', value: 'pago-nfc' }); + }); +}); + +describe('parsePaymentAmount', () => { + it('maps exact stroop values without floating-point drift', () => { + const parsed = parsePaymentAmount('10.5', 'XLM'); + expect(parsed.stroops).toBe(105000000n); + expect(parsed.stellarAmount).toBe('10.5000000'); + expect(stroopsToStellarAmount(parsed.stroops)).toBe('10.5000000'); + }); + + it('rejects malformed amount strings with INVALID_AMOUNT semantics', () => { + expect(() => parsePaymentAmount('1.2.3', 'XLM')).toThrow(PaymentTxValidationError); + expect(() => parsePaymentAmount('1.2.3', 'XLM')).toThrow(PAYMENT_TX_ERRORS.invalidAmountFormat); + }); +}); diff --git a/src/features/wallet/services/__tests__/TrustlineService.test.ts b/src/features/wallet/services/__tests__/TrustlineService.test.ts deleted file mode 100644 index dbe5b36..0000000 --- a/src/features/wallet/services/__tests__/TrustlineService.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { TrustlineService } from '@/features/wallet/services/TrustlineService'; - -const mockLoadAccount = jest.fn(); - -jest.mock('@stellar/stellar-sdk', () => ({ - Horizon: { - Server: jest.fn().mockImplementation(() => ({ - loadAccount: mockLoadAccount, - })), - }, -})); - -// jest.setup.ts sets EXPO_PUBLIC_USDC_ISSUER to this value. -const USDC_ISSUER = 'GBBD47IF6LWK7P7MUGHC2XLYUUXV6ZLW75PN7CHLIW2NSIW74UZEST66'; -const PUBLIC_KEY = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWXY'; - -describe('TrustlineService', () => { - beforeEach(() => { - mockLoadAccount.mockReset(); - }); - - it('returns hasLine: true when the account has a matching USDC trustline', async () => { - mockLoadAccount.mockResolvedValue({ - balances: [ - { asset_type: 'native', balance: '100' }, - { - asset_type: 'credit_alphanum4', - asset_code: 'USDC', - asset_issuer: USDC_ISSUER, - balance: '50', - }, - ], - }); - - const service = new TrustlineService(); - const result = await service.checkUsdcTrustline(PUBLIC_KEY); - - expect(result).toEqual({ hasLine: true, sufficientReserve: true }); - }); - - it('returns hasLine: false when no USDC trustline entry exists', async () => { - mockLoadAccount.mockResolvedValue({ - balances: [{ asset_type: 'native', balance: '100' }], - }); - - const service = new TrustlineService(); - const result = await service.checkUsdcTrustline(PUBLIC_KEY); - - // sufficientReserve is a static true for MVP (see TrustlineService) except - // when the account lookup itself fails — that's the "not found" case below. - expect(result).toEqual({ hasLine: false, sufficientReserve: true }); - }); - - it('returns hasLine: false when the account cannot be found', async () => { - mockLoadAccount.mockRejectedValue(new Error('Not Found')); - - const service = new TrustlineService(); - const result = await service.checkUsdcTrustline(PUBLIC_KEY); - - expect(result).toEqual({ hasLine: false, sufficientReserve: false }); - }); -}); diff --git a/src/features/wallet/services/walletErrors.test.ts b/src/features/wallet/services/walletErrors.test.ts new file mode 100644 index 0000000..ee905c7 --- /dev/null +++ b/src/features/wallet/services/walletErrors.test.ts @@ -0,0 +1,183 @@ +jest.mock('@stellar/stellar-sdk', () => { + class MockNotFoundError extends Error { + constructor(message?: string) { + super(message ?? 'Not Found'); + this.name = 'NotFoundError'; + } + } + + return { + NotFoundError: MockNotFoundError, + Networks: { + PUBLIC: 'Public Global Stellar Network ; September 2015', + TESTNET: 'Test SDF Network ; September 2015', + }, + }; +}); + +import { NotFoundError } from '@stellar/stellar-sdk'; + +import { PaymentTxValidationError, PAYMENT_TX_ERRORS } from '@/features/wallet/schemas/paymentTx'; +import { + createWalletError, + getWalletErrorMessage, + mapAffordabilityReason, + mapHorizonError, + mapPaymentValidationError, + sanitizeWalletError, + WalletErrorCode, +} from './walletErrors'; + +function horizonSubmitError(resultCodes: { + transaction?: string; + operations?: string[]; +}): Error & { response: { status: number; data: { extras: { result_codes: typeof resultCodes } } } } { + return { + name: 'HorizonError', + message: 'Transaction failed', + response: { + status: 400, + data: { + extras: { + result_codes: resultCodes, + }, + }, + }, + } as never; +} + +describe('mapHorizonError', () => { + it.each([ + ['NotFoundError', new NotFoundError('missing', {}), WalletErrorCode.ACCOUNT_NOT_FOUND], + [ + 'op_underfunded', + horizonSubmitError({ transaction: 'tx_failed', operations: ['op_underfunded'] }), + WalletErrorCode.INSUFFICIENT_BALANCE, + ], + [ + 'op_no_trust', + horizonSubmitError({ transaction: 'tx_failed', operations: ['op_no_trust'] }), + WalletErrorCode.NO_USDC_TRUSTLINE, + ], + [ + 'op_no_trustline', + horizonSubmitError({ transaction: 'tx_failed', operations: ['op_no_trustline'] }), + WalletErrorCode.NO_USDC_TRUSTLINE, + ], + [ + 'op_no_destination', + horizonSubmitError({ transaction: 'tx_failed', operations: ['op_no_destination'] }), + WalletErrorCode.INVALID_RECIPIENT, + ], + [ + 'op_line_full', + horizonSubmitError({ transaction: 'tx_failed', operations: ['op_line_full'] }), + WalletErrorCode.INSUFFICIENT_BALANCE, + ], + [ + 'tx_bad_seq', + horizonSubmitError({ transaction: 'tx_bad_seq', operations: [] }), + WalletErrorCode.BAD_SEQUENCE, + ], + [ + 'tx_too_early', + horizonSubmitError({ transaction: 'tx_too_early', operations: [] }), + WalletErrorCode.TRANSACTION_FAILED, + ], + [ + 'tx_too_late', + horizonSubmitError({ transaction: 'tx_too_late', operations: [] }), + WalletErrorCode.TRANSACTION_FAILED, + ], + [ + 'generic 5xx', + { response: { status: 503, data: {} } }, + WalletErrorCode.NETWORK_ERROR, + ], + ['network TypeError', new TypeError('Network request failed'), WalletErrorCode.NETWORK_ERROR], + ['AbortError', Object.assign(new Error('aborted'), { name: 'AbortError' }), WalletErrorCode.TIMEOUT], + ['unknown error', new Error('something else'), WalletErrorCode.UNKNOWN], + ] as const)('maps %s to %s', (_label, error, expectedCode) => { + const result = mapHorizonError(error); + expect(result.code).toBe(expectedCode); + }); + + it('prioritizes operation result codes over transaction codes', () => { + const result = mapHorizonError( + horizonSubmitError({ + transaction: 'tx_bad_seq', + operations: ['op_underfunded'], + }) + ); + + expect(result.code).toBe(WalletErrorCode.INSUFFICIENT_BALANCE); + }); +}); + +describe('mapAffordabilityReason', () => { + it.each([ + ['account_not_found', WalletErrorCode.ACCOUNT_NOT_FOUND], + ['network_error', WalletErrorCode.NETWORK_ERROR], + ['insufficient_balance', WalletErrorCode.INSUFFICIENT_BALANCE], + ['insufficient_xlm_for_fee_and_reserve', WalletErrorCode.INSUFFICIENT_RESERVE], + ['no_usdc_trustline', WalletErrorCode.NO_USDC_TRUSTLINE], + ] as const)('maps %s to %s', (reason, expectedCode) => { + expect(mapAffordabilityReason(reason)).toBe(expectedCode); + }); +}); + +describe('mapPaymentValidationError', () => { + it('maps amount validation failures to INVALID_AMOUNT', () => { + const result = mapPaymentValidationError( + new PaymentTxValidationError(PAYMENT_TX_ERRORS.amountZero) + ); + + expect(result.code).toBe(WalletErrorCode.INVALID_AMOUNT); + }); + + it('maps schema validation failures to INVALID_PAYMENT_PARAMS', () => { + const result = mapPaymentValidationError( + new PaymentTxValidationError(PAYMENT_TX_ERRORS.invalidSource) + ); + + expect(result.code).toBe(WalletErrorCode.INVALID_PAYMENT_PARAMS); + }); +}); + +describe('getWalletErrorMessage', () => { + it('returns a Spanish message for every WalletErrorCode value', () => { + for (const code of Object.values(WalletErrorCode)) { + const message = getWalletErrorMessage(code); + expect(message.length).toBeGreaterThan(0); + expect(message).toBe(createWalletError(code).message); + } + }); +}); + +describe('createWalletError', () => { + it('preserves cause internally while exposing a safe message', () => { + const cause = { response: { data: { secret: 'must-not-leak' } } }; + const result = createWalletError(WalletErrorCode.NETWORK_ERROR, cause); + + expect(result.cause).toBe(cause); + expect(result.message).toBe(getWalletErrorMessage(WalletErrorCode.NETWORK_ERROR)); + expect(result.retryable).toBe(true); + }); +}); + +describe('sanitizeWalletError', () => { + it('never exposes cause or raw Horizon payloads', () => { + const sanitized = sanitizeWalletError( + createWalletError(WalletErrorCode.TRANSACTION_FAILED, { + response: { data: { extras: { result_codes: { operations: ['op_underfunded'] } } } }, + }) + ); + + expect(sanitized).toEqual({ + code: WalletErrorCode.TRANSACTION_FAILED, + message: getWalletErrorMessage(WalletErrorCode.TRANSACTION_FAILED), + }); + expect(sanitized).not.toHaveProperty('cause'); + expect(JSON.stringify(sanitized)).not.toContain('op_underfunded'); + }); +}); diff --git a/src/features/wallet/services/walletErrors.ts b/src/features/wallet/services/walletErrors.ts new file mode 100644 index 0000000..550a9b7 --- /dev/null +++ b/src/features/wallet/services/walletErrors.ts @@ -0,0 +1,277 @@ +/** + * CLI-041 — Wallet error mapping + * + * Maps Horizon/SDK failures to stable WalletErrorCode values with user-safe + * Spanish messages. Never expose raw Horizon payloads to UI or analytics. + * + * @see authErrors.ts — architectural pattern reference + */ +import { NotFoundError } from '@stellar/stellar-sdk'; + +import { + PAYMENT_TX_ERRORS, + PaymentTxValidationError, +} from '@/features/wallet/schemas/paymentTx'; +import type { AffordabilityReason } from './FeeService'; +import type { EnsureUsdcTrustlineStatus } from './TrustlineService'; + +export const WalletErrorCode = { + ACCOUNT_NOT_FOUND: 'ACCOUNT_NOT_FOUND', + INVALID_RECIPIENT: 'INVALID_RECIPIENT', + INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE', + INSUFFICIENT_RESERVE: 'INSUFFICIENT_RESERVE', + NO_USDC_TRUSTLINE: 'NO_USDC_TRUSTLINE', + INVALID_AMOUNT: 'INVALID_AMOUNT', + INVALID_PAYMENT_PARAMS: 'INVALID_PAYMENT_PARAMS', + BAD_SEQUENCE: 'BAD_SEQUENCE', + TRANSACTION_FAILED: 'TRANSACTION_FAILED', + NETWORK_ERROR: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT', + WALLET_KEY_MISSING: 'WALLET_KEY_MISSING', + CONFIG_ERROR: 'CONFIG_ERROR', + UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION', + UNKNOWN: 'UNKNOWN', +} as const; + +export type WalletErrorCodeValue = (typeof WalletErrorCode)[keyof typeof WalletErrorCode]; + +export interface WalletError { + code: WalletErrorCodeValue; + message: string; + cause?: unknown; + retryable?: boolean; +} + +const WALLET_ERROR_MESSAGES: Record = { + ACCOUNT_NOT_FOUND: + 'No encontramos tu cuenta en la red Stellar. Verifica que esté activa.', + INVALID_RECIPIENT: 'La cuenta destinataria no existe o no es válida.', + INSUFFICIENT_BALANCE: 'Saldo insuficiente para completar este pago.', + INSUFFICIENT_RESERVE: + 'No tienes suficiente XLM para cubrir la comisión y la reserva mínima de la cuenta.', + NO_USDC_TRUSTLINE: 'USDC no está habilitado en tu billetera. Actívalo antes de enviar.', + INVALID_AMOUNT: 'El monto no es válido. Revisa la cantidad e inténtalo de nuevo.', + INVALID_PAYMENT_PARAMS: 'Los datos del pago no son válidos.', + BAD_SEQUENCE: 'La transacción expiró o la secuencia cambió. Inténtalo de nuevo.', + TRANSACTION_FAILED: 'No se pudo enviar la transacción. Inténtalo de nuevo.', + NETWORK_ERROR: + 'No pudimos conectar con la red. Verifica tu conexión e inténtalo de nuevo.', + TIMEOUT: 'La operación tardó demasiado. Inténtalo de nuevo.', + WALLET_KEY_MISSING: 'No se encontró la llave de la billetera en este dispositivo.', + CONFIG_ERROR: 'La configuración de USDC no está disponible.', + UNSUPPORTED_OPERATION: 'La operación no está disponible en este entorno.', + UNKNOWN: 'Ocurrió un error inesperado. Inténtalo de nuevo.', +}; + +const RETRYABLE_CODES = new Set([ + WalletErrorCode.NETWORK_ERROR, + WalletErrorCode.TIMEOUT, + WalletErrorCode.BAD_SEQUENCE, +]); + +const AMOUNT_VALIDATION_MESSAGES = new Set([ + PAYMENT_TX_ERRORS.invalidAmountFormat, + PAYMENT_TX_ERRORS.amountTooManyDecimals, + PAYMENT_TX_ERRORS.amountZero, +]); + +interface HorizonResultCodes { + transaction?: string; + operations?: string[]; +} + +interface HorizonErrorShape { + response?: { + status?: number; + data?: { + extras?: { + result_codes?: HorizonResultCodes; + }; + }; + }; +} + +function isNotFoundError(error: unknown): boolean { + if ( + error !== null && + typeof error === 'object' && + 'name' in error && + (error as { name: unknown }).name === 'NotFoundError' + ) { + return true; + } + + if (typeof NotFoundError === 'function') { + return error instanceof NotFoundError; + } + + return false; +} + +function isAbortError(error: unknown): boolean { + return ( + error !== null && + typeof error === 'object' && + 'name' in error && + (error as { name: unknown }).name === 'AbortError' + ); +} + +function extractResultCodes(error: unknown): HorizonResultCodes | null { + if (error === null || typeof error !== 'object') { + return null; + } + + const resultCodes = (error as HorizonErrorShape).response?.data?.extras?.result_codes; + if (!resultCodes) { + return null; + } + + return resultCodes; +} + +function extractHttpStatus(error: unknown): number | null { + if (error === null || typeof error !== 'object') { + return null; + } + + const status = (error as HorizonErrorShape).response?.status; + return typeof status === 'number' ? status : null; +} + +function mapOperationResultCode(operationCode: string): WalletErrorCodeValue | null { + switch (operationCode) { + case 'op_underfunded': + case 'op_line_full': + return WalletErrorCode.INSUFFICIENT_BALANCE; + case 'op_no_trust': + case 'op_no_trustline': + return WalletErrorCode.NO_USDC_TRUSTLINE; + case 'op_no_destination': + return WalletErrorCode.INVALID_RECIPIENT; + default: + return null; + } +} + +function mapTransactionResultCode(transactionCode: string): WalletErrorCodeValue | null { + switch (transactionCode) { + case 'tx_bad_seq': + return WalletErrorCode.BAD_SEQUENCE; + case 'tx_too_early': + case 'tx_too_late': + case 'tx_failed': + return WalletErrorCode.TRANSACTION_FAILED; + default: + return null; + } +} + +function mapHorizonResultCodes(resultCodes: HorizonResultCodes): WalletErrorCodeValue | null { + const operations = resultCodes.operations ?? []; + + for (const operationCode of operations) { + const mapped = mapOperationResultCode(operationCode); + if (mapped) { + return mapped; + } + } + + if (resultCodes.transaction) { + return mapTransactionResultCode(resultCodes.transaction); + } + + return null; +} + +function isNetworkError(error: unknown): boolean { + const status = extractHttpStatus(error); + if (status !== null && status >= 500) { + return true; + } + + return error instanceof TypeError; +} + +export function getWalletErrorMessage(code: WalletErrorCodeValue): string { + return WALLET_ERROR_MESSAGES[code]; +} + +export function createWalletError(code: WalletErrorCodeValue, cause?: unknown): WalletError { + return { + code, + message: WALLET_ERROR_MESSAGES[code], + cause, + retryable: RETRYABLE_CODES.has(code), + }; +} + +export function mapHorizonError(error: unknown): WalletError { + if (isNotFoundError(error)) { + return createWalletError(WalletErrorCode.ACCOUNT_NOT_FOUND, error); + } + + if (isAbortError(error)) { + return createWalletError(WalletErrorCode.TIMEOUT, error); + } + + const resultCodes = extractResultCodes(error); + if (resultCodes) { + const mappedCode = mapHorizonResultCodes(resultCodes); + if (mappedCode) { + return createWalletError(mappedCode, error); + } + + return createWalletError(WalletErrorCode.TRANSACTION_FAILED, error); + } + + if (isNetworkError(error)) { + return createWalletError(WalletErrorCode.NETWORK_ERROR, error); + } + + return createWalletError(WalletErrorCode.UNKNOWN, error); +} + +export function mapAffordabilityReason(reason: AffordabilityReason): WalletErrorCodeValue { + switch (reason) { + case 'account_not_found': + return WalletErrorCode.ACCOUNT_NOT_FOUND; + case 'network_error': + return WalletErrorCode.NETWORK_ERROR; + case 'insufficient_balance': + return WalletErrorCode.INSUFFICIENT_BALANCE; + case 'insufficient_xlm_for_fee_and_reserve': + return WalletErrorCode.INSUFFICIENT_RESERVE; + case 'no_usdc_trustline': + return WalletErrorCode.NO_USDC_TRUSTLINE; + } +} + +export function mapPaymentValidationError(error: unknown): WalletError { + if (error instanceof PaymentTxValidationError) { + const code = AMOUNT_VALIDATION_MESSAGES.has(error.message) + ? WalletErrorCode.INVALID_AMOUNT + : WalletErrorCode.INVALID_PAYMENT_PARAMS; + + return createWalletError(code, error); + } + + return createWalletError(WalletErrorCode.UNKNOWN, error); +} + +export function mapEnsureTrustlineStatus( + status: Extract +): WalletErrorCodeValue { + if (status === 'insufficient_reserve') { + return WalletErrorCode.INSUFFICIENT_RESERVE; + } + + return WalletErrorCode.UNKNOWN; +} + +export function sanitizeWalletError(err: WalletError): { + code: WalletErrorCodeValue; + message: string; +} { + return { code: err.code, message: err.message }; +} diff --git a/src/features/wallet/utils/stellarReserve.test.ts b/src/features/wallet/utils/stellarReserve.test.ts new file mode 100644 index 0000000..886d381 --- /dev/null +++ b/src/features/wallet/utils/stellarReserve.test.ts @@ -0,0 +1,77 @@ +import { + getMinimumBalanceStroops, + getMinimumBalanceStroopsForNewTrustline, + getMinimumBalanceXlmForNewTrustline, + horizonBalanceToStroops, + STELLAR_BASE_RESERVE_STROOPS, + STELLAR_BASE_RESERVE_XLM, + STROOP_FACTOR, + TRUSTLINE_RESERVE_BUFFER_STROOPS, + TRUSTLINE_RESERVE_BUFFER_XLM, +} from './stellarReserve'; + +describe('horizonBalanceToStroops', () => { + it('converts Horizon decimal strings without floating-point math', () => { + expect(horizonBalanceToStroops('123.4567890')).toBe(1234567890n); + expect(horizonBalanceToStroops('0.0000001')).toBe(1n); + }); + + it('pads and truncates fractional digits to 7 decimal places', () => { + expect(horizonBalanceToStroops('1.5')).toBe(15000000n); + expect(horizonBalanceToStroops('1.123456789')).toBe(11234567n); + }); + + it('returns 0n for malformed balances', () => { + expect(horizonBalanceToStroops('')).toBe(0n); + expect(horizonBalanceToStroops('not-a-number')).toBe(0n); + }); +}); + +describe('getMinimumBalanceStroops', () => { + it('uses payment reserve formula (2 + subentryCount) * base reserve with no buffer', () => { + expect(getMinimumBalanceStroops(0)).toBe(10_000_000n); + expect(getMinimumBalanceStroops(3)).toBe(25_000_000n); + expect(getMinimumBalanceStroops(10)).toBe(60_000_000n); + }); + + it('matches base reserve constants in stroops', () => { + expect(STELLAR_BASE_RESERVE_STROOPS).toBe(BigInt(STELLAR_BASE_RESERVE_XLM * 10_000_000)); + expect(getMinimumBalanceStroops(0)).toBe(2n * STELLAR_BASE_RESERVE_STROOPS); + }); +}); + +describe('getMinimumBalanceStroopsForNewTrustline', () => { + it('adds one subentry and the trustline buffer stroops', () => { + expect(getMinimumBalanceStroopsForNewTrustline(0)).toBe( + 3n * STELLAR_BASE_RESERVE_STROOPS + TRUSTLINE_RESERVE_BUFFER_STROOPS + ); + expect(getMinimumBalanceStroopsForNewTrustline(3)).toBe( + 6n * STELLAR_BASE_RESERVE_STROOPS + TRUSTLINE_RESERVE_BUFFER_STROOPS + ); + }); + + it('includes the 0.01 XLM buffer only for trustline creation', () => { + expect(TRUSTLINE_RESERVE_BUFFER_STROOPS).toBe( + horizonBalanceToStroops(String(TRUSTLINE_RESERVE_BUFFER_XLM)) + ); + expect( + getMinimumBalanceStroops(0) + TRUSTLINE_RESERVE_BUFFER_STROOPS < + getMinimumBalanceStroopsForNewTrustline(0) + ).toBe(true); + }); +}); + +describe('getMinimumBalanceXlmForNewTrustline', () => { + it('matches legacy Number-based trustline checks used by TrustlineService', () => { + expect(getMinimumBalanceXlmForNewTrustline(0)).toBe(1.51); + expect(getMinimumBalanceXlmForNewTrustline(3)).toBe(3.01); + }); + + it('aligns stroops and XLM formulas for subentry_count 0', () => { + const stroopsMinimum = getMinimumBalanceStroopsForNewTrustline(0); + const whole = stroopsMinimum / STROOP_FACTOR; + const fraction = stroopsMinimum % STROOP_FACTOR; + const fractionStr = fraction.toString().padStart(7, '0'); + expect(Number(`${whole}.${fractionStr}`)).toBe(getMinimumBalanceXlmForNewTrustline(0)); + }); +}); diff --git a/src/features/wallet/utils/stellarReserve.ts b/src/features/wallet/utils/stellarReserve.ts new file mode 100644 index 0000000..681fd35 --- /dev/null +++ b/src/features/wallet/utils/stellarReserve.ts @@ -0,0 +1,56 @@ +/** Stellar base reserve per ledger entry — MVP constant (0.5 XLM). */ +export const STELLAR_BASE_RESERVE_XLM = 0.5; + +/** Buffer applied only when adding a new trustline subentry (changeTrust flow). */ +export const TRUSTLINE_RESERVE_BUFFER_XLM = 0.01; + +export const STROOP_FACTOR = 10n ** 7n; + +/** 0.5 XLM expressed in stroops. */ +export const STELLAR_BASE_RESERVE_STROOPS = 5_000_000n; + +/** 0.01 XLM buffer expressed in stroops — trustline creation only. */ +export const TRUSTLINE_RESERVE_BUFFER_STROOPS = 100_000n; + +const HORIZON_BALANCE_REGEX = /^(\d+)(?:\.(\d+))?$/; + +/** + * Converts a Horizon decimal balance string to stroops without floating-point math. + */ +export function horizonBalanceToStroops(balance: string): bigint { + const trimmed = balance.trim(); + const match = trimmed.match(HORIZON_BALANCE_REGEX); + + if (!match) { + return 0n; + } + + const wholePart = match[1]; + const fractionPart = (match[2] ?? '').padEnd(7, '0').slice(0, 7); + return BigInt(`${wholePart}${fractionPart}`); +} + +/** + * Minimum account balance for an existing account (payment affordability). + * Formula: (2 + subentryCount) * baseReserve — no extra subentry. + */ +export function getMinimumBalanceStroops(subentryCount: number): bigint { + const ledgerEntries = 2n + BigInt(subentryCount); + return ledgerEntries * STELLAR_BASE_RESERVE_STROOPS; +} + +/** + * Minimum native balance required before adding one trustline subentry. + * Formula: (2 + subentryCount + 1) * baseReserve + buffer. + */ +export function getMinimumBalanceStroopsForNewTrustline(subentryCount: number): bigint { + const ledgerEntries = 2n + BigInt(subentryCount) + 1n; + return ledgerEntries * STELLAR_BASE_RESERVE_STROOPS + TRUSTLINE_RESERVE_BUFFER_STROOPS; +} + +/** Decimal XLM minimum for trustline creation — used by legacy Number-based checks. */ +export function getMinimumBalanceXlmForNewTrustline(subentryCount: number): number { + return ( + (2 + subentryCount + 1) * STELLAR_BASE_RESERVE_XLM + TRUSTLINE_RESERVE_BUFFER_XLM + ); +} diff --git a/src/lib/toast.ts b/src/lib/toast.ts index e30d47b..4aa7bf7 100644 --- a/src/lib/toast.ts +++ b/src/lib/toast.ts @@ -4,6 +4,10 @@ * * Policy: never pass private keys, seeds, full NFC payloads, or other secrets in messages. */ +import { + getWalletErrorMessage, + type WalletErrorCodeValue, +} from '@/features/wallet/services/walletErrors'; function showSuccess(message: string) { console.log(`[toast.success] ${message}`); @@ -13,7 +17,12 @@ function showError(message: string) { console.error(`[toast.error] ${message}`); } +function showWalletError(code: WalletErrorCodeValue) { + showError(getWalletErrorMessage(code)); +} + export const toast = { success: showSuccess, error: showError, + walletError: showWalletError, };