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.
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, andFeeServiceaffordability checks using stroops/bigint. - Error taxonomy — stable
WalletErrorCodevalues viawalletErrors.ts. - Auth integration —
AuthGuardgates protected tabs on local wallet readiness; Settings shows the Stellar G-address; logout clears Stellar keys and resetswalletStore.
What C09 does not include: a complete send flow (sign + submit from UI),
receive settlement polling, or on-chain funding verification inside AuthGuard.
The real onboarding path after passkey registration:
- Passkey —
useAuth.registerPasskey()persistsPASSKEY_CREDENTIAL_IDandWALLET_PUBLIC_KEY(credential ID placeholder), then navigates to/(onboarding)/wallet-setup. - Keypair —
useWallet.createWallet()callsAccountService.getOrCreateKeypair()(reads or generatesWALLET_STELLAR_PUBLIC_KEY/WALLET_STELLAR_SECRET_KEYin SecureKeyStore). - Funding — on testnet,
AccountService.fundTestnetAccount()callsStellarHorizonClient.fundWithFriendbot(). On mainnet, if the account does not exist on Horizon, status becomesawaiting_fundingand the user must deposit XLM externally (FundWalletView+checkFunding()). - Trustline —
finishSetup()callsensureUsdcTrustline()(signs and submitschangeTrustwhen needed). - Balances —
fetchBalances()loads XLM/USDC from Horizon intowalletStore. - Ready —
walletStore.statusbecomesready;WalletSetupViewredirects to/(tabs)/receive.
| 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 |
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
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.
AuthGuard wraps the tab navigator (src/app/(tabs)/_layout.tsx) and runs
after auth state is resolved. It performs no network calls.
| 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) |
When auth.status === 'READY', the guard allows tab content only if:
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.
/(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.
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 |
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)
createWallet—idle|error→creating→ready|awaiting_funding|errorcheckFunding—awaiting_funding→readywhenaccountExistsOnNetworkis true- Hydration —
idle→readywhen a Stellar public key exists in SecureKeyStore reset(logout) — any →idle(full initial state,hasHydrated: false)
AccountService fulfills the role described as WalletService in the C09 specification. There is no separate
WalletService.tsin 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 |
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.
paymentTx schema (validate + parsePaymentAmount)
→ TransactionBuilder.buildPaymentTx() [unsigned tx]
→ FeeService.canAffordPayment() [affordability only]
- Validation —
PaymentTxValidationErrorfrom Zod / amount rules before any Horizon call. - Build — loads source account sequence from Horizon; returns unsigned
Transaction(fee =BASE_FEE, default timeout 300s). - Affordability —
AffordabilityResultwithcanAffordand optionalAffordabilityReason; uses payment reserve formula (no trustline buffer).
→ 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.
Implemented in src/features/wallet/utils/stellarReserve.ts.
minimumBalance = (2 + subentryCount) × 0.5 XLM
Used by getMinimumBalanceStroops() → FeeService.canAffordPayment() and
BalanceService.getMinimumBalanceStroops. No extra subentry and no
0.01 XLM buffer.
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.
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().
Keep Horizon/SDK details in services; expose stable codes/messages to UI via
walletErrors.ts (and toast.walletError() where integrated).
| 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 |
| 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.
| 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 |
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.TESTNETorNetworks.PUBLICenv.stellarNetwork,env.horizonUrl,env.rpcUrl,env.usdcIssuer
Mainnet guardrail: if EXPO_PUBLIC_STELLAR_NETWORK=mainnet, Horizon/RPC URLs
must not contain testnet.
useAuth.logout() performs:
PasskeyService.revoke()— clears passkey credential artifacts- Deletes
WALLET_STELLAR_PUBLIC_KEY,WALLET_STELLAR_SECRET_KEY, andSESSION_LAST_ACTIVEfrom SecureKeyStore useWalletStore.getState().reset()— clears status, publicKey, balances, error, andhasHydrateddispatch({ type: 'LOGOUT' })— auth returns toUNAUTHENTICATED
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.
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:
npm test -- --testPathPattern="TransactionBuilder|stellarReserve|AccountService|FeeService|BalanceService|TrustlineService|walletErrors|StellarHorizonClient|AuthGuard|SettingsAuthSection|useAuth.logout"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_fundinguntil 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; expectinsufficient_balanceorinsufficient_xlm_for_fee_and_reserve. - Invalid destination —
buildPaymentTxwith invalid G-address throwsPaymentTxValidationErrorbefore 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.
- Stellar SDK ADR — SDK choice, polyfills, dev-client requirements
- Product flows & system definition — overall MVP scope
- Receive payment flow (C12) — receive FSM and trustline read checks