From 005cc28fdd829aa0a5213a17dde97f634d8749ba Mon Sep 17 00:00:00 2001 From: BigManly4 <294554482+BigManly4@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:36:47 -0700 Subject: [PATCH] feat(mobile): add vault screen for deposit/withdraw/escrow management Port the vault sub-account flow from the Next.js wallet (frontend/wallet/app/vault/page.tsx and frontend/wallet/lib/vault.ts) to Expo/React Native, following the conventions already established by the bulk-payout and swap mobile ports. - frontend/mobile/lib/vault.ts: typed vault domain logic ported from the wallet lib (amount/delay parsing and formatting, address validation, withdrawal status/countdown helpers). Chain submission and reads are behind injectable VaultSubmit/VaultFetch parameters, matching the executeBulkPayout(rows, submitBatch) pattern already used in lib/bulkPayout.ts, since mobile has no passkey/session signing infrastructure ported yet. - frontend/mobile/lib/escrow.ts: claimable-balance escrow helpers (createEscrow, claimEscrow, reclaimEscrow, buildEscrowClaimants). The wallet's version is a one-line re-export of sdk/src/claimableBalance.ts; that re-export does not typecheck from frontend/mobile because the mobile TS program only resolves @stellar/stellar-sdk from frontend/mobile/node_modules and there is no hoisted root node_modules, so this file mirrors that implementation locally using the @stellar/stellar-sdk dependency mobile already has. - frontend/mobile/app/vault.tsx: React Native screen covering vault creation/attach, balance display, deposit, and the full withdrawal lifecycle (queue with delay, live countdown, execute once ready, cancel while pending). Styled to match the existing dark-theme mobile screens. Signing/submission and vault reads go through stub functions declared at the top of the screen (stubSubmitVaultTx, stubFetchVaultDetails) so the flow is exercisable today and can be swapped for real Soroban RPC calls once mobile signing lands. Verified: npm run typecheck is clean, and npx expo export --platform web bundles /vault as a static route with no errors. Not verified: an interactive simulator/device render. --- frontend/mobile/app/vault.tsx | 718 ++++++++++++++++++++++++++++++++++ frontend/mobile/lib/escrow.ts | 183 +++++++++ frontend/mobile/lib/vault.ts | 228 +++++++++++ 3 files changed, 1129 insertions(+) create mode 100644 frontend/mobile/app/vault.tsx create mode 100644 frontend/mobile/lib/escrow.ts create mode 100644 frontend/mobile/lib/vault.ts 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)); +}