Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions docs/Deposit-Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
The `/billing` deposit modal signs transactions with Freighter and submits vault
deposits through Stellar RPC. Shared configuration lives in
`src/config/constants.ts`; wallet operations live in `src/services/walletService.ts`.
On-chain balance reads use `src/services/vaultReads.ts` and the `useVaultBalances`
hook in `src/hooks/useVaultBalances.ts`.

## Asset and network configuration

Expand All @@ -19,8 +21,44 @@ Freighter must use `STELLAR_NETWORK_PASSPHRASE`. `STELLAR_HORIZON_URL` selects t
same network for classic account lookups and trustline submission. Vault deposits
require `VITE_STELLAR_VAULT_CONTRACT_ID` and the configured Soroban RPC endpoint.

## On-chain balance reads

Balances are read via read-only Soroban simulation (no signatures, no ledger
writes):

- **Vault balance**: `vault.balance()` simulated against `STELLAR_VAULT_CONTRACT_ID`
- **USDC token**: `vault.get_usdc_token()` simulated once per session, cached
- **Wallet balance**: `balance(address)` simulated on the USDC Stellar Asset Contract

All three use `scValToNative` and keep stroops as `bigint` until formatting via
`formatUsdcStroops` (exact decimal, no `Number` conversion for values > 2^53).

The `useVaultBalances({ address })` hook returns:
- `vault` / `wallet` — stroops (`bigint | null`)
- `status` — `"idle" | "loading" | "ready" | "error"`
- `error` — human-readable failure reason
- `configured` — false when no vault contract is configured
- `refresh()` — debounced re-read (300 ms, in-flight dedupe)
- `updatedAt` — epoch ms of last successful read
- `revision` — increments on each successful read

Wallet read only runs once a Freighter address is known. Superseded responses are
ignored via a version counter. Loading shows fixed-size skeletons (`aria-busy`);
errors show an inline message with **Retry** and never render `0` as a balance.
A live region announces balance changes politely.

When no vault contract is configured (`STELLAR_VAULT_CONTRACT_ID` missing or
invalid), no RPC calls are made and the UI shows "Vault not configured".

Simulation source account: the connected address when available; otherwise the
fixed valid account `GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`
(safe because simulation never checks sequence or signatures and nothing is
submitted).

## Trustline step

... (rest unchanged)

Opening the modal checks Freighter, connects the selected account, verifies its
network, then calls `hasUsdcTrustline(address)`. It fetches Horizon
`GET /accounts/{address}` and matches **both** `asset_code` and `asset_issuer` in
Expand Down Expand Up @@ -77,16 +115,22 @@ The amount starts at 50 USDC. `MIN_DEPOSIT` is 10 and presets are 10, 50, 100, a
500. Input normalizes separators and limits USDC precision to seven decimal places.
Validation rejects blank, nonnumeric, below-minimum, and above-displayed-wallet
amounts. The service independently validates positive amounts and precision.
The displayed wallet and vault balances remain in-memory UI values; this issue
does not add an authoritative balance lookup.
Wallet and vault balances are read from Soroban via `useVaultBalances`; the Max
button fills the real wallet balance (truncated to 7 decimals), and validation
uses the chain-sourced wallet balance.

Approval reconnects the wallet and checks its current trustline again before
Soroban simulation, protecting against account changes since the initial check.
A newly missing line or unfunded account returns to the corresponding prerequisite
step. An unknown lookup permits simulation. The service prepares the vault's
`deposit(caller, amount)` invocation, signs via Freighter, submits to RPC, and
polls confirmation. The explorer link uses the hash returned by RPC. Only
confirmed deposits update the displayed vault balance.
polls confirmation. The explorer link uses the hash returned by RPC.

**Balance read-back after deposit**: on confirmation, the modal sets an optimistic
vault balance (pre-deposit vault + deposit amount) labelled "pending refresh" and
calls `refresh()`. Once the debounced re-read completes, the optimistic value is
replaced with the fresh chain value. The revision counter ensures stale responses
are ignored.

Approving, pending, and trustline submission disable amount/preset/Max/Close/Cancel
controls. A definitive deposit failure permits **Retry deposit**, preserving the
Expand Down
24 changes: 23 additions & 1 deletion src/App.deposit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ const walletMocks = vi.hoisted(() => ({
network: "testnet",
}));

// Mock the vault balances hook to return predictable values matching the old
// hardcoded balances (284.62 vault / 1260.5 wallet) so existing tests pass.
// 284.62 USDC = 2,846,200,000 stroops (10^7 scale)
// 1260.5 USDC = 12,605,000,000 stroops
const vaultBalancesMock = vi.hoisted(() => ({
vault: 2846200000n, // 284.62 USDC in stroops
wallet: 12605000000n, // 1260.5 USDC in stroops
status: "ready" as const,
error: null,
configured: true,
refresh: vi.fn().mockResolvedValue(undefined),
updatedAt: Date.now(),
revision: 1,
}));

vi.mock("@stellar/freighter-api", () => ({
isConnected: async () => ({ isConnected: true }),
isAllowed: async () => ({ isAllowed: true }),
Expand Down Expand Up @@ -85,6 +100,12 @@ vi.mock("./services/walletService", async (importOriginal) => {
};
});

vi.mock("./hooks/useVaultBalances", () => ({
useVaultBalances: () => vaultBalancesMock,
BalanceReadStatus: "idle" | "loading" | "ready" | "error",
VaultBalanceStatus: "idle" | "loading" | "ready" | "error",
}));

beforeEach(() => {
// Node's TextEncoder returns a Node-realm Uint8Array. The SDK validates
// these bytes with instanceof, so align jsdom's constructor for real XDR.
Expand Down Expand Up @@ -365,7 +386,8 @@ describe("App deposit modal timed transitions", () => {
expect(preset100.getAttribute("aria-checked")).toBe("true");

fireEvent.click(screen.getByRole("button", { name: /Max/i }));
expect(amountInput.value).toBe("1260.50");
// Max now uses the exact wallet balance from Soroban (no trailing zeros)
expect(amountInput.value).toBe("1260.5");
});

it("drives approving to pending to confirmed and updates the vault balance", async () => {
Expand Down
Loading