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,
};