diff --git a/frontend/mobile/app/vault.tsx b/frontend/mobile/app/vault.tsx new file mode 100644 index 00000000..51149f5b --- /dev/null +++ b/frontend/mobile/app/vault.tsx @@ -0,0 +1,718 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { + cancelVaultWithdrawal, + deployAndInitializeVault, + depositToVault, + executeVaultWithdrawal, + fetchVaultDetails, + formatCountdown, + formatDelay, + formatTimestamp, + isWithdrawalPending, + isWithdrawalReady, + parseDelaySeconds, + queueVaultWithdrawal, + withdrawalStatus, + type VaultDetails, + type VaultFetch, + type VaultSubmit, + type VaultTxResult, + type VaultWithdrawal, +} from '../lib/vault'; + +type DelayUnit = 'hours' | 'days'; + +/** + * Stub submitter — mobile does not yet have the passkey/session signing + * infrastructure the web wallet uses (see frontend/wallet/lib/passkeyAuth). + * Replace this with a real signer once device-key or session-based signing + * lands on mobile. It returns a fake hash so the UI flow can be exercised. + */ +const stubSubmitVaultTx: VaultSubmit = async (call) => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { hash: `stub-${call.method}-${Date.now().toString(36)}` } satisfies VaultTxResult; +}; + +/** + * Stub reader — replace with a Soroban RPC-backed implementation (mirroring + * frontend/wallet/lib/vault.ts fetchVaultDetails) once mobile has network + * config wired up. Returns a deterministic empty vault so the screen renders. + */ +const stubFetchVaultDetails: VaultFetch = async (contractId) => { + await new Promise((resolve) => setTimeout(resolve, 400)); + return { + contractId, + config: { owner: '', token: '', delaySeconds: 86_400 }, + balanceStroops: 0n, + balanceXlm: '0', + reservedStroops: 0n, + reservedXlm: '0', + availableStroops: 0n, + availableXlm: '0', + withdrawals: [], + } satisfies VaultDetails; +}; + +export default function VaultScreen() { + const [contractId, setContractId] = useState(null); + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(false); + const [action, setAction] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [now, setNow] = useState(() => Math.floor(Date.now() / 1_000)); + + const [delayValue, setDelayValue] = useState('24'); + const [delayUnit, setDelayUnit] = useState('hours'); + const [existingContract, setExistingContract] = useState(''); + const [depositAmount, setDepositAmount] = useState(''); + const [recipient, setRecipient] = useState(''); + const [withdrawalAmount, setWithdrawalAmount] = useState(''); + + useEffect(() => { + const timer = setInterval(() => setNow(Math.floor(Date.now() / 1_000)), 1_000); + return () => clearInterval(timer); + }, []); + + const loadDetails = async (address?: string) => { + const target = address || contractId; + if (!target) return; + + setLoading(true); + setError(null); + try { + const next = await fetchVaultDetails(target, stubFetchVaultDetails); + setDetails(next); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (contractId) void loadDetails(contractId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractId]); + + const pendingWithdrawals = useMemo( + () => details?.withdrawals ?? [], + [details], + ); + + async function runAction(name: string, operation: () => Promise): Promise { + setAction(name); + setError(null); + setNotice(null); + try { + const result = await operation(); + const hash = typeof result === 'string' ? result : result.hash; + setNotice(`Transaction submitted: ${hash.slice(0, 16)}...`); + await loadDetails(); + return true; + } catch (cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause); + setError(message); + return false; + } finally { + setAction(null); + } + } + + async function handleDeploy(): Promise { + let parsedDelay: number; + try { + parsedDelay = parseDelaySeconds(delayValue, delayUnit); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + return; + } + + await runAction('deploy', async () => { + const hash = await deployAndInitializeVault({ delaySeconds: parsedDelay }, stubSubmitVaultTx); + // The stub does not return a real contract id; in production this would + // come back from the deploy transaction result the same way the web + // wallet extracts it in frontend/wallet/lib/vault.ts (extractContractId). + const address = `C-stub-${Date.now().toString(36)}`; + setContractId(address); + return hash; + }); + } + + async function handleAttach(): Promise { + setAction('attach'); + setError(null); + try { + const next = await fetchVaultDetails(existingContract, stubFetchVaultDetails); + setDetails(next); + setContractId(next.contractId); + setExistingContract(''); + } catch (cause: unknown) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(null); + } + } + + async function handleDeposit(): Promise { + if (!contractId) return; + const succeeded = await runAction('deposit', () => depositToVault( + { contractId, amountXlm: depositAmount }, + stubSubmitVaultTx, + )); + if (succeeded) setDepositAmount(''); + } + + async function handleQueue(): Promise { + if (!contractId) return; + const succeeded = await runAction('queue', () => queueVaultWithdrawal( + { contractId, to: recipient, amountXlm: withdrawalAmount }, + stubSubmitVaultTx, + )); + if (succeeded) { + setRecipient(''); + setWithdrawalAmount(''); + } + } + + function handleCancel(withdrawal: VaultWithdrawal): void { + if (!contractId) return; + void runAction(`cancel-${withdrawal.id}`, () => cancelVaultWithdrawal( + { contractId, withdrawalId: withdrawal.id }, + stubSubmitVaultTx, + )); + } + + function handleExecute(withdrawal: VaultWithdrawal): void { + if (!contractId) return; + void runAction(`execute-${withdrawal.id}`, () => executeVaultWithdrawal( + { contractId, withdrawalId: withdrawal.id }, + stubSubmitVaultTx, + )); + } + + function forgetVault(): void { + Alert.alert('Remove vault?', 'Funds remain on-chain — you can re-attach this vault later.', [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Remove', + style: 'destructive', + onPress: () => { + setContractId(null); + setDetails(null); + setNotice(null); + setError(null); + }, + }, + ]); + } + + return ( + + TIME-LOCKED SUB-ACCOUNT + Vault + + Hold XLM behind a withdrawal delay. Every transfer must be queued, remains + cancellable, and can execute only after its timer expires. + + + {error && {error}} + {notice && {notice}} + + {!contractId ? ( + + + Create a vault + + The active account becomes the owner. The delay is fixed for this vault + after deployment. + + + + + DELAY + + + + UNIT + + setDelayUnit('hours')} + > + Hours + + setDelayUnit('days')} + > + Days + + + + + + + {action === 'deploy' ? ( + + ) : ( + Deploy vault + )} + + + + + Use an existing vault + + Attach a vault already deployed on the selected Stellar network. + + setExistingContract(value.trim())} + autoCapitalize="none" + autoCorrect={false} + /> + + {action === 'attach' ? ( + + ) : ( + Attach vault + )} + + + + ) : ( + + + + + VAULT CONTRACT + {contractId} + + + Remove + + + + {loading && !details ? ( + + ) : details && ( + <> + + + + + + + + OWNER + {details.config.owner || 'Unknown'} + + + )} + + + + Deposit XLM + Move XLM from the active account into this vault. + AMOUNT + + + {action === 'deposit' ? ( + + ) : ( + Deposit + )} + + + + + Queue withdrawal + + The destination and amount cannot change after queueing. + + DESTINATION + setRecipient(value.trim())} + autoCapitalize="none" + autoCorrect={false} + /> + AMOUNT + + + {action === 'queue' ? ( + + ) : ( + Queue withdrawal + )} + + + + + + Withdrawal queue + loadDetails()} disabled={loading || action !== null}> + {loading ? 'Refreshing...' : 'Refresh'} + + + + {pendingWithdrawals.length === 0 ? ( + + No withdrawals have been queued. + + ) : ( + pendingWithdrawals.map((withdrawal) => { + const pending = isWithdrawalPending(withdrawal); + const ready = isWithdrawalReady(withdrawal, now); + const status = withdrawalStatus(withdrawal, now); + const busy = action === `cancel-${withdrawal.id}` || action === `execute-${withdrawal.id}`; + + return ( + + + Withdrawal #{withdrawal.id} + {status} + + + + + + + + {pending && ( + + handleCancel(withdrawal)} + > + + {action === `cancel-${withdrawal.id}` ? 'Cancelling...' : 'Cancel'} + + + {ready && ( + handleExecute(withdrawal)} + > + + {action === `execute-${withdrawal.id}` ? 'Executing...' : 'Execute'} + + + )} + + )} + + {busy && Waiting for Stellar confirmation...} + + ); + }) + )} + + + )} + + ); +} + +function statusColor(withdrawal: VaultWithdrawal, ready: boolean) { + if (withdrawal.executed) return { color: '#34d399' }; + if (withdrawal.cancelled) return { color: '#64748b' }; + if (ready) return { color: '#facc15' }; + return { color: '#f1f5f9' }; +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( + + {label.toUpperCase()} + {value} + + ); +} + +function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( + + {label} + {value} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexGrow: 1, + backgroundColor: '#0B0B0F', + padding: 24, + gap: 16, + }, + eyebrow: { + color: '#6366f1', + fontSize: 11, + fontWeight: '700', + letterSpacing: 1, + }, + title: { + color: '#FFFFFF', + fontSize: 28, + fontWeight: '700', + }, + subtitle: { + color: '#9BA1A6', + fontSize: 15, + lineHeight: 21, + }, + form: { + gap: 16, + }, + card: { + backgroundColor: '#1e293b', + borderRadius: 12, + padding: 16, + gap: 4, + borderWidth: 1, + borderColor: '#334155', + }, + withdrawalCard: { + gap: 8, + }, + sectionTitle: { + color: '#f1f5f9', + fontSize: 16, + fontWeight: '700', + }, + sectionCopy: { + color: '#94a3b8', + fontSize: 13, + lineHeight: 18, + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 10, + }, + inputFlex: { + flex: 1, + }, + inputAsset: { + flex: 1, + }, + label: { + color: '#64748b', + fontSize: 11, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 6, + marginTop: 10, + }, + input: { + backgroundColor: '#0f172a', + borderRadius: 10, + padding: 14, + color: '#f1f5f9', + fontSize: 16, + borderWidth: 1, + borderColor: '#334155', + }, + mono: { + fontFamily: 'monospace', + fontSize: 12, + }, + unitToggle: { + flexDirection: 'row', + backgroundColor: '#0f172a', + borderRadius: 10, + borderWidth: 1, + borderColor: '#334155', + overflow: 'hidden', + }, + unitOption: { + flex: 1, + paddingVertical: 14, + alignItems: 'center', + }, + unitOptionActive: { + backgroundColor: '#6366f1', + }, + unitOptionText: { + color: '#f1f5f9', + fontSize: 14, + fontWeight: '600', + }, + btn: { + borderRadius: 10, + paddingVertical: 14, + alignItems: 'center', + }, + btnPrimary: { + backgroundColor: '#6366f1', + }, + btnSecondary: { + backgroundColor: '#334155', + }, + btnDisabled: { + opacity: 0.4, + }, + btnText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + withdrawalBtn: { + flex: 1, + }, + address: { + color: '#a5b4fc', + fontFamily: 'monospace', + fontSize: 12, + marginTop: 2, + }, + statsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 16, + marginTop: 14, + padding: 12, + borderRadius: 10, + backgroundColor: '#0f172a', + }, + stat: { + minWidth: 100, + }, + statValue: { + color: '#f1f5f9', + fontFamily: 'monospace', + fontSize: 15, + marginTop: 2, + }, + error: { + color: '#f87171', + fontSize: 13, + backgroundColor: '#450a0a', + borderRadius: 8, + padding: 12, + }, + notice: { + color: '#34d399', + fontSize: 13, + backgroundColor: '#052e1f', + borderRadius: 8, + padding: 12, + }, + remove: { + color: '#f87171', + fontSize: 13, + }, + refresh: { + color: '#a5b4fc', + fontSize: 13, + }, + idBadge: { + color: '#f1f5f9', + fontSize: 11, + fontWeight: '700', + backgroundColor: '#334155', + borderRadius: 100, + paddingHorizontal: 10, + paddingVertical: 4, + overflow: 'hidden', + }, + statusBadge: { + fontSize: 12, + fontWeight: '700', + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + infoLabel: { + color: '#64748b', + fontSize: 12, + flexShrink: 0, + }, + infoValue: { + color: '#f1f5f9', + fontSize: 12, + textAlign: 'right', + flexShrink: 1, + }, +}); diff --git a/frontend/mobile/lib/escrow.ts b/frontend/mobile/lib/escrow.ts new file mode 100644 index 00000000..01d88eba --- /dev/null +++ b/frontend/mobile/lib/escrow.ts @@ -0,0 +1,183 @@ +/** + * Escrow (Stellar claimable balance) helpers for mobile. + * + * The web wallet re-exports this whole surface from `sdk/src/claimableBalance.ts` + * (`frontend/wallet/lib/escrow.ts` is a one-line `export * from '../../../sdk/src/claimableBalance'`). + * The same relative path resolves from `frontend/mobile/lib` too, but that file + * imports `@stellar/stellar-sdk`, and the mobile TypeScript program only + * resolves modules from `frontend/mobile/node_modules` — there is no hoisted + * root `node_modules`, so `tsc` cannot find `@stellar/stellar-sdk` when the + * sdk file is pulled in via a blind `export *`. Mobile already depends on + * `@stellar/stellar-sdk` directly, so this file mirrors the sdk implementation + * locally instead of reaching outside the mobile package. + */ +import { + Asset, + BASE_FEE, + Claimant, + Horizon, + Keypair, + Operation, + TransactionBuilder, +} from '@stellar/stellar-sdk'; + +export type EscrowConfig = { + /** Stellar Horizon REST API base URL (e.g. "https://horizon-testnet.stellar.org"). + * Must be a Horizon URL — NOT a Soroban RPC endpoint. */ + horizonUrl: string; + networkPassphrase: string; +}; + +export type CreateEscrowOptions = { + senderKeypair: Keypair; + recipientAddress: string; + amount: string; + asset: Asset; + /** Duration in seconds from now before the sender can reclaim the balance. */ + claimDeadlineSeconds: number; + config: EscrowConfig; +}; + +export type EscrowResult = { + balanceId: string; + claimLink: string; + expiresAt: number; +}; + +export type ClaimOptions = { + claimantKeypair: Keypair; + balanceId: string; + config: EscrowConfig; +}; + +export type ReclaimOptions = { + senderKeypair: Keypair; + balanceId: string; + config: EscrowConfig; +}; + +export function buildClaimLink(balanceId: string): string { + return `https://app.veil.xyz/claim/${balanceId}`; +} + +/** + * Build the two Stellar Claimant entries for an escrow balance: + * - recipient: may claim unconditionally at any time. + * - sender: may reclaim ONLY after `deadlineUnix` (unix timestamp). + * + * A Stellar claimable balance is consumed by the first successful claim. + * Once either party claims, the balance is gone — the other party's subsequent + * claim will be rejected by the Stellar network. Design UIs accordingly. + */ +export function buildEscrowClaimants( + recipientAddress: string, + senderAddress: string, + deadlineUnix: number, +): Claimant[] { + const recipientClaimant = new Claimant(recipientAddress, Claimant.predicateUnconditional()); + const senderClaimant = new Claimant( + senderAddress, + Claimant.predicateNot(Claimant.predicateBeforeAbsoluteTime(deadlineUnix.toString())), + ); + return [recipientClaimant, senderClaimant]; +} + +/** + * Create a Stellar claimable balance escrow. + * + * The recipient may claim immediately (unconditional predicate). + * The sender may reclaim after `claimDeadlineSeconds` has elapsed. + * Claim and reclaim are mutually exclusive — the first successful claim + * consumes the balance. + * + * @throws If Horizon does not return a balance_id in its response. + */ +export async function createEscrow(options: CreateEscrowOptions): Promise { + const { senderKeypair, recipientAddress, amount, asset, claimDeadlineSeconds, config } = options; + const server = new Horizon.Server(config.horizonUrl); + + const account = await server.loadAccount(senderKeypair.publicKey()); + const expiresAt = Math.floor(Date.now() / 1000) + claimDeadlineSeconds; + + const claimants = buildEscrowClaimants(recipientAddress, senderKeypair.publicKey(), expiresAt); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation( + Operation.createClaimableBalance({ + asset, + amount, + claimants, + }), + ) + .setTimeout(180) + .build(); + + tx.sign(senderKeypair); + + const result = await server.submitTransaction(tx); + const balanceId = (result as unknown as { balance_id?: string }).balance_id; + if (!balanceId) { + throw new Error('create_claimable_balance: missing balance_id in Horizon response'); + } + + return { + balanceId, + claimLink: buildClaimLink(balanceId), + expiresAt, + }; +} + +/** + * Claim a claimable balance as the recipient. + * + * Once claimed the balance is consumed. A subsequent reclaimEscrow() call + * by the sender will be rejected by the Stellar network. + */ +export async function claimEscrow(options: ClaimOptions): Promise<{ txHash: string }> { + const { claimantKeypair, balanceId, config } = options; + const server = new Horizon.Server(config.horizonUrl); + + const account = await server.loadAccount(claimantKeypair.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(Operation.claimClaimableBalance({ balanceId })) + .setTimeout(180) + .build(); + + tx.sign(claimantKeypair); + + const result = await server.submitTransaction(tx); + return { txHash: result.hash }; +} + +/** + * Reclaim a claimable balance as the sender after the deadline has passed. + * + * Will be rejected by the Stellar network if called before `expiresAt` or + * if the recipient has already claimed the balance. + */ +export async function reclaimEscrow(options: ReclaimOptions): Promise<{ txHash: string }> { + const { senderKeypair, balanceId, config } = options; + const server = new Horizon.Server(config.horizonUrl); + + const account = await server.loadAccount(senderKeypair.publicKey()); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: config.networkPassphrase, + }) + .addOperation(Operation.claimClaimableBalance({ balanceId })) + .setTimeout(180) + .build(); + + tx.sign(senderKeypair); + + const result = await server.submitTransaction(tx); + return { txHash: result.hash }; +} diff --git a/frontend/mobile/lib/vault.ts b/frontend/mobile/lib/vault.ts new file mode 100644 index 00000000..41023b6c --- /dev/null +++ b/frontend/mobile/lib/vault.ts @@ -0,0 +1,228 @@ +import { Address } from '@stellar/stellar-sdk'; + +const STROOPS_PER_XLM = 10_000_000n; + +export interface VaultConfig { + owner: string; + token: string; + delaySeconds: number; +} + +export interface VaultWithdrawal { + id: number; + to: string; + amountStroops: bigint; + amountXlm: string; + queuedAt: number; + unlockAt: number; + cancelled: boolean; + executed: boolean; +} + +export interface VaultDetails { + contractId: string; + config: VaultConfig; + balanceStroops: bigint; + balanceXlm: string; + reservedStroops: bigint; + reservedXlm: string; + availableStroops: bigint; + availableXlm: string; + withdrawals: VaultWithdrawal[]; +} + +/** Result of a submitted vault transaction. */ +export type VaultTxResult = { + hash: string; +}; + +/** + * Injected submitter for vault contract calls. Mobile has no passkey / session + * signing infrastructure ported yet, so the screen supplies a stub today and a + * real implementation (device key, WalletConnect, etc.) can be swapped in later + * without touching this file. + */ +export type VaultSubmit = (call: { + method: 'deploy' | 'deposit' | 'queue_withdrawal' | 'cancel_withdrawal' | 'execute_withdrawal'; + contractId?: string; + params: Record; +}) => Promise; + +/** Injected reader for vault contract state. The screen supplies a stub or a + * real Soroban RPC-backed implementation. */ +export type VaultFetch = (contractId: string) => Promise; + +export function xlmToStroops(value: string): bigint { + const amount = value.trim(); + if (!/^\d+(?:\.\d{0,7})?$/.test(amount)) { + throw new Error('Enter a valid XLM amount with no more than 7 decimal places.'); + } + + const [whole, fraction = ''] = amount.split('.'); + const stroops = BigInt(whole) * STROOPS_PER_XLM + BigInt(fraction.padEnd(7, '0')); + + if (stroops <= 0n) { + throw new Error('Amount must be greater than zero.'); + } + return stroops; +} + +export function stroopsToXlm(stroops: bigint): string { + const negative = stroops < 0n; + const absolute = negative ? -stroops : stroops; + const whole = absolute / STROOPS_PER_XLM; + const fraction = (absolute % STROOPS_PER_XLM) + .toString() + .padStart(7, '0') + .replace(/0+$/, ''); + return `${negative ? '-' : ''}${whole}${fraction ? `.${fraction}` : ''}`; +} + +export function validateStellarAddress(value: string): string { + const address = value.trim(); + try { + Address.fromString(address); + } catch { + throw new Error('Enter a valid Stellar G... or C... address.'); + } + return address; +} + +export function formatDelay(seconds: number): string { + if (seconds % 86_400 === 0) { + const days = seconds / 86_400; + return `${days} day${days === 1 ? '' : 's'}`; + } + if (seconds % 3_600 === 0) { + const hours = seconds / 3_600; + return `${hours} hour${hours === 1 ? '' : 's'}`; + } + return `${seconds.toLocaleString()} seconds`; +} + +export function formatTimestamp(timestamp: number): string { + return new Date(timestamp * 1_000).toLocaleString(); +} + +export function formatCountdown(unlockAt: number, now: number): string { + const remaining = Math.max(0, unlockAt - now); + if (remaining === 0) return 'Ready to execute'; + + const days = Math.floor(remaining / 86_400); + const hours = Math.floor((remaining % 86_400) / 3_600); + const minutes = Math.floor((remaining % 3_600) / 60); + const seconds = remaining % 60; + + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`; + return `${minutes}m ${seconds}s`; +} + +export function withdrawalStatus(withdrawal: VaultWithdrawal, now: number): string { + if (withdrawal.executed) return 'Executed'; + if (withdrawal.cancelled) return 'Cancelled'; + if (now >= withdrawal.unlockAt) return 'Ready'; + return 'Time-locked'; +} + +export function isWithdrawalPending(withdrawal: VaultWithdrawal): boolean { + return !withdrawal.cancelled && !withdrawal.executed; +} + +export function isWithdrawalReady(withdrawal: VaultWithdrawal, now: number): boolean { + return isWithdrawalPending(withdrawal) && now >= withdrawal.unlockAt; +} + +/** Validate and normalize a whole-number withdrawal delay expressed in a given unit. */ +export function parseDelaySeconds(value: string, unit: 'hours' | 'days'): number { + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error('Enter a positive whole-number withdrawal delay.'); + } + const multiplier = unit === 'days' ? 86_400 : 3_600; + return parsed * multiplier; +} + +/** + * Deploy and initialize a new vault. Actual contract deployment and signing is + * delegated to `submit` (see `VaultSubmit`). + */ +export async function deployAndInitializeVault( + params: { delaySeconds: number }, + submit: VaultSubmit, +): Promise { + if (!Number.isSafeInteger(params.delaySeconds) || params.delaySeconds <= 0) { + throw new Error('Withdrawal delay must be a positive whole number of seconds.'); + } + + const result = await submit({ + method: 'deploy', + params: { delaySeconds: params.delaySeconds }, + }); + return result.hash; +} + +export async function depositToVault( + params: { contractId: string; amountXlm: string }, + submit: VaultSubmit, +): Promise { + const contractId = validateStellarAddress(params.contractId); + const amountStroops = xlmToStroops(params.amountXlm); + + return submit({ + method: 'deposit', + contractId, + params: { amountStroops }, + }); +} + +export async function queueVaultWithdrawal( + params: { contractId: string; to: string; amountXlm: string }, + submit: VaultSubmit, +): Promise { + const contractId = validateStellarAddress(params.contractId); + const to = validateStellarAddress(params.to); + const amountStroops = xlmToStroops(params.amountXlm); + + return submit({ + method: 'queue_withdrawal', + contractId, + params: { to, amountStroops }, + }); +} + +export async function cancelVaultWithdrawal( + params: { contractId: string; withdrawalId: number }, + submit: VaultSubmit, +): Promise { + const contractId = validateStellarAddress(params.contractId); + + return submit({ + method: 'cancel_withdrawal', + contractId, + params: { withdrawalId: params.withdrawalId }, + }); +} + +export async function executeVaultWithdrawal( + params: { contractId: string; withdrawalId: number }, + submit: VaultSubmit, +): Promise { + const contractId = validateStellarAddress(params.contractId); + + return submit({ + method: 'execute_withdrawal', + contractId, + params: { withdrawalId: params.withdrawalId }, + }); +} + +/** Fetch vault details via the injected reader. Kept as a thin wrapper so the + * screen has one call site regardless of whether `fetchDetails` talks to + * Soroban RPC directly or goes through a backend proxy. */ +export async function fetchVaultDetails( + contractId: string, + fetchDetails: VaultFetch, +): Promise { + return fetchDetails(validateStellarAddress(contractId)); +}