Files
wallet/ui/src/api/wallet.ts
T
grenadeandClaude Fable 5.1 c59ba49e90
ci / gate (push) Waiting to run
feat(swap): orders as a state machine persisted in SQLite, and the preview screen
wallet_core::orders is the pure part: Quoted → AwaitingDeposit →
Processing → Success | Refunded | Failed, with Expired when a quote's
deadline passes before a deposit is seen; it never moves backwards, a
quote past its deadline expires without a network call, and the poll
delay doubles from ten seconds to five minutes (five seconds while
processing). Orders persist through an OrderStore port on the same
SQLite file; on start the app follows every order still open, and the
follower asks the provider with backoff and jitter, persists each move
and emits orders:updated.

Commands: swap_assets (what 1Click lists and the registry maps),
swap_preview (a dry quote), swap_order_create (a wet quote persisted
with its deposit address and deadline) and orders_list. The swap screen
sits behind a preview setting, says plainly that every pair today is
paid from an external wallet, quotes, shows the deposit address with a
QR, memo and deadline, and lists orders with their states.

Driven live on the dev network: BTC on bitcoin to USDC on near, 0.001
BTC quoted at 75.568313 USDC, at least 74.812629, about 805 s. Reserving
a deposit address failed with 1Click's "Failed to get quote": a wet
quote needs the partner credential the relay will hold (#40), which is
the operator's registration. Until then the screen previews and the
poller has nothing to follow; the state machine and the store are
covered by unit tests, including the mid-order restart the store makes
possible.

Refs #39
Refs #41

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
2026-09-16 10:37:36 +03:00

224 lines
7.7 KiB
TypeScript

// The only file in the webview that talks to Rust. One function per Tauri
// command, each typed with the ts-rs output in ./generated, and one place
// where a Rust `WalletError` becomes a thrown `WalletError`. Screens never
// call `invoke` themselves; CI counts the occurrences in this directory.
import { invoke } from '@tauri-apps/api/core'
import type { AccountBalance } from './generated/AccountBalance'
import type { AppInfo } from './generated/AppInfo'
import type { ChainId } from './generated/ChainId'
import type { ChainProfile } from './generated/ChainProfile'
import type { ChainStatus } from './generated/ChainStatus'
import type { PendingReversible } from './generated/PendingReversible'
import type { PreparedTransferInfo } from './generated/PreparedTransferInfo'
import type { CreationStart } from './generated/CreationStart'
import type { SessionStatusInfo } from './generated/SessionStatusInfo'
import type { Settings } from './generated/Settings'
import type { AssetInfo } from './generated/AssetInfo'
import type { HistoryCursor } from './generated/HistoryCursor'
import type { Order } from './generated/Order'
import type { SwapAsk } from './generated/SwapAsk'
import type { SwapPreview } from './generated/SwapPreview'
import type { HistoryPage } from './generated/HistoryPage'
import type { SignatureScheme } from './generated/SignatureScheme'
import type { WalletSummary } from './generated/WalletSummary'
import type { WormholeSummary } from './generated/WormholeSummary'
import type { WalletError as WalletErrorShape } from './generated/WalletError'
export class WalletError extends Error {
readonly kind: WalletErrorShape['kind']
readonly detail: string
constructor(shape: WalletErrorShape) {
const detail = 'detail' in shape ? shape.detail : ''
super(detail ? `${shape.kind}: ${detail}` : shape.kind)
this.name = 'WalletError'
this.kind = shape.kind
this.detail = detail
}
}
function isWalletErrorShape(e: unknown): e is WalletErrorShape {
return (
typeof e === 'object' &&
e !== null &&
'kind' in e &&
typeof (e as { kind: unknown }).kind === 'string'
)
}
// Rust commands return `Result<T, WalletError>`; Tauri rejects the promise
// with the serialised error. Anything that is not a WalletError is a bug on
// one side of the boundary and is rethrown as-is so it is loud.
async function command<T>(name: string, args?: Record<string, unknown>): Promise<T> {
try {
return await invoke<T>(name, args)
} catch (e) {
if (isWalletErrorShape(e)) throw new WalletError(e)
throw e
}
}
export function appInfo(): Promise<AppInfo> {
return command<AppInfo>('app_info')
}
export function walletsList(): Promise<WalletSummary[]> {
return command<WalletSummary[]>('wallets_list')
}
// Creation is three steps so the phrase is shown once and then gone: begin
// returns the words and the challenge, confirm writes the wallet if the
// challenge passes, cancel forgets the phrase.
export function walletCreateBegin(scheme: SignatureScheme): Promise<CreationStart> {
return command<CreationStart>('wallet_create_begin', { scheme })
}
export function walletCreateConfirm(
name: string,
password: string,
answers: string[],
): Promise<WalletSummary> {
return command<WalletSummary>('wallet_create_confirm', { name, password, answers })
}
export function walletCreateCancel(): Promise<void> {
return command<void>('wallet_create_cancel')
}
export function walletImportPhrase(
name: string,
password: string,
phrase: string,
scheme: SignatureScheme,
): Promise<WalletSummary> {
return command<WalletSummary>('wallet_import_phrase', { name, password, phrase, scheme })
}
export function walletImportFile(path: string, password: string): Promise<WalletSummary> {
return command<WalletSummary>('wallet_import_file', { path, password })
}
export function checkphrase(address: string): Promise<string> {
return command<string>('checkphrase', { address })
}
// The session: one wallet open at a time, locked again after idling. The
// Rust side emits `session:locked` when the idle rule fires.
export function unlock(name: string, password: string): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('unlock', { name, password })
}
export function lock(): Promise<void> {
return command<void>('lock')
}
export function sessionStatus(): Promise<SessionStatusInfo> {
return command<SessionStatusInfo>('session_status')
}
export function chainsList(): Promise<ChainProfile[]> {
return command<ChainProfile[]>('chains_list')
}
export function chainConnect(chain: ChainId): Promise<ChainStatus> {
return command<ChainStatus>('chain_connect', { chain })
}
export function chainStatus(chain: ChainId): Promise<ChainStatus> {
return command<ChainStatus>('chain_status', { chain })
}
// Balances are read from finalized state. `balancesWatch` makes Rust re-read
// them on every finalized head and emit `balances:updated`.
export function balances(chain: ChainId): Promise<AccountBalance[]> {
return command<AccountBalance[]>('balances', { chain })
}
export function balancesWatch(chain: ChainId): Promise<void> {
return command<void>('balances_watch', { chain })
}
// Sending is two steps so the review screen shows what Rust decoded from the
// bytes: prepare returns the summary and fee under an id, submit signs and
// sends that id. Progress arrives as `tx:status` events.
export function transferPrepare(
chain: ChainId,
from: string,
to: string,
amount: string,
reversibleFor?: number,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('transfer_prepare', {
chain,
from,
to,
amount,
reversibleFor: reversibleFor ?? null,
})
}
export function transferSubmit(id: string): Promise<string> {
return command<string>('transfer_submit', { id })
}
export function transferDiscard(id: string): Promise<void> {
return command<void>('transfer_discard', { id })
}
export function reversiblePending(chain: ChainId, address: string): Promise<PendingReversible[]> {
return command<PendingReversible[]>('reversible_pending', { chain, address })
}
export function reversibleCancelPrepare(
chain: ChainId,
from: string,
txId: string,
): Promise<PreparedTransferInfo> {
return command<PreparedTransferInfo>('reversible_cancel_prepare', { chain, from, txId })
}
export function settingsGet(): Promise<Settings> {
return command<Settings>('settings_get')
}
export function settingsSet(settings: Settings): Promise<Settings> {
return command<Settings>('settings_set', { settings })
}
// History: one page per call, from the indexer or, when it cannot be
// reached, from the local cache with `from_cache` set.
export function historyPage(
chain: ChainId,
address: string,
before?: HistoryCursor | null,
): Promise<HistoryPage> {
return command<HistoryPage>('history_page', { chain, address, before: before ?? null })
}
// The wormhole address behind the wallet's mining rewards and what the
// zk-tree holds for it. Scans on the Rust side; can take a while the first
// time on a long tree.
export function wormholeSummary(chain: ChainId): Promise<WormholeSummary> {
return command<WormholeSummary>('wormhole_summary', { chain })
}
// Swaps, in preview: the assets the provider and the registry agree on, a
// dry quote, an order (a wet quote persisted with its deposit address),
// and the list. Rust follows open orders and emits `orders:updated`.
export function swapAssets(): Promise<AssetInfo[]> {
return command<AssetInfo[]>('swap_assets')
}
export function swapPreview(ask: SwapAsk): Promise<SwapPreview> {
return command<SwapPreview>('swap_preview', { ask })
}
export function swapOrderCreate(ask: SwapAsk): Promise<Order> {
return command<Order>('swap_order_create', { ask })
}
export function ordersList(): Promise<Order[]> {
return command<Order[]>('orders_list')
}