diff --git a/src/components/StellarReceive.tsx b/src/components/StellarReceive.tsx index c5b3ca5..30be8bc 100644 --- a/src/components/StellarReceive.tsx +++ b/src/components/StellarReceive.tsx @@ -1,4 +1,3 @@ -// @ts-nocheck (temporary: wave-6 merges left stale symbol names; unblocks CI) import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { @@ -43,11 +42,38 @@ import { useStealthLabels } from '@/hooks/useStealthLabels'; import { StellarBatchWithdrawModal } from '@/components/StellarBatchWithdrawModal'; import { createStellarQrUri } from '@/utils/qr'; import { readScanCursor, writeScanCursor, type RetentionGap } from '@/lib/stellar/scannerCursor'; +import type { StellarAssetKey } from '@/lib/stellar/assets'; import { fetchAnnouncementEvents } from '@/lib/stellar/announcementRpc'; const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; const REGISTRY_CONTRACT = 'CC2LAUCXYOPJ4DV4CYXNXYAXRDVOTMAWFF76W4WFD5OVQBD6TN4PYYJ5'; +interface ScanWorkerMessage { + type: 'SUCCESS' | 'RETENTION_GAP' | 'ERROR'; + results?: MatchedAnnouncement[]; + nextLedger?: number; + requestedLedger?: number; + oldestAvailableLedger?: number; + error?: string; +} + +interface SorobanServerMock { + getAccount(address: string): Promise<{ accountId(): string; sequenceNumber(): string }>; + simulateTransaction(tx: unknown): Promise; + sendTransaction(tx: unknown): Promise<{ status: string; hash: string }>; + getTransaction(hash: string): Promise<{ status: string }>; +} + +type WindowWithMocks = Window & { + scanAnnouncementsMock?: ( + announcements: Announcement[], + viewingKey: Uint8Array, + spendingPubKey: Uint8Array, + spendingScalar: bigint, + ) => MatchedAnnouncement[]; + sorobanServerMock?: SorobanServerMock; +}; + // Fetches announcements on the main thread. Only used to support the // `window.scanAnnouncementsMock` test hook (see e2e/fixtures.ts) which needs // main-thread access to the parsed announcements. The real, production scan @@ -76,6 +102,7 @@ function StellarMatchCardContainer({ onTagClick, showPrivacyWarning, onDismissPrivacyWarning, + onBalanceFetched, }: { match: MatchedAnnouncement; onWithdrawn: () => void; @@ -86,6 +113,7 @@ function StellarMatchCardContainer({ onTagClick: (tag: string) => void; showPrivacyWarning: boolean; onDismissPrivacyWarning: () => void; + onBalanceFetched: (addr: string, bal: string) => void; isSelected?: boolean; onToggleSelect?: () => void; }) { @@ -105,6 +133,7 @@ function StellarMatchCardContainer({ const [showKey, setShowKey] = useState(false); const [showSponsorPrompt, setShowSponsorPrompt] = useState(false); const [showNetworkModal, setShowNetworkModal] = useState(false); + const [loadingBal, setLoadingBal] = useState(true); const { upsert } = useActivity(); const scalarHex = match.stealthPrivateScalar.toString(16).padStart(64, '0'); @@ -112,6 +141,7 @@ function StellarMatchCardContainer({ useEffect(() => { (async () => { try { + setLoadingBal(true); const res = await fetchWithRetry( `${STELLAR_NETWORK.horizonUrl}/accounts/${match.stealthAddress}`, {}, @@ -122,7 +152,7 @@ function StellarMatchCardContainer({ setBalances({ XLM: '0' }); return; } - const data = await res.json(); + const data = (await res.json()) as { balances?: Array<{ asset_type: string; balance: string }> }; const parsed = parseAssetBalances(data.balances || []); setBalances(parsed); } catch { @@ -130,6 +160,7 @@ function StellarMatchCardContainer({ setBalances({ XLM: '0' }); } finally { setBalanceState('loaded'); + setLoadingBal(false); } })(); }, [match.stealthAddress]); @@ -140,7 +171,7 @@ function StellarMatchCardContainer({ onBalanceFetched(match.stealthAddress, bal); }, [balances, match.stealthAddress, onBalanceFetched]); - const hasAnyBalance = Object.values(balances).some((b) => parseFloat(b) > 0); + const balance = balances['XLM']; const withdrawAssetInfo = getAssetByKey(withdrawAssetKey); const withdrawBalance = parseFloat(balances[withdrawAssetKey] || '0'); @@ -169,14 +200,18 @@ function StellarMatchCardContainer({ ); setRetryStatus(''); if (!res.ok) throw new Error('Account not found'); - const account = await res.json(); + const account = (await res.json()) as { + sequence: string; + balances?: Array<{ asset_type: string; balance: string }>; + subentry_count?: number; + }; if (withdrawBalance <= 0) throw new Error(`No ${withdrawAssetKey} balance`); const sourceAccount = new Account(match.stealthAddress, account.sequence); if (withdrawAssetKey === 'XLM') { - const xlmBal = account.balances?.find( + const xlmBal = (account.balances as Array<{ asset_type: string; balance: string }>)?.find( (b: { asset_type: string }) => b.asset_type === 'native', ); if (!xlmBal || parseFloat(xlmBal.balance) === 0) throw new Error('No XLM balance'); @@ -237,7 +272,11 @@ function StellarMatchCardContainer({ body: `tx=${signedXdrStr}`, }); - const submitData = await submitRes.json(); + const submitData = (await submitRes.json()) as { + hash: string; + title?: string; + extras?: { result_codes?: { transaction?: string } }; + }; if (!submitRes.ok) { throw new Error( submitData.extras?.result_codes?.transaction || @@ -288,7 +327,11 @@ function StellarMatchCardContainer({ body: `tx=${signedXdrStr}`, }); - const submitData = await submitRes.json(); + const submitData = (await submitRes.json()) as { + hash: string; + title?: string; + extras?: { result_codes?: { transaction?: string } }; + }; if (!submitRes.ok) { throw new Error( submitData.extras?.result_codes?.transaction || @@ -302,7 +345,6 @@ function StellarMatchCardContainer({ trackEvent('withdraw'); onWithdrawn(); } catch (err) { - setError(err instanceof Error ? err.message : t('common.transactionFailed')); setError(err instanceof Error ? err.message : 'Withdraw failed'); // In a real robust implementation we'd check if we submitted and mark failed setRetryStatus(''); @@ -344,9 +386,12 @@ function StellarMatchCardContainer({ ); setRetryStatus(''); if (!stealthRes.ok) throw new Error('Stealth account not found'); - const stealthAccount = await stealthRes.json(); + const stealthAccount = (await stealthRes.json()) as { + sequence: string; + balances?: Array<{ asset_type: string; balance: string }>; + }; - const xlmBal = stealthAccount.balances?.find( + const xlmBal = (stealthAccount.balances as Array<{ asset_type: string; balance: string }>)?.find( (b: { asset_type: string }) => b.asset_type === 'native', ); if (!xlmBal || parseFloat(xlmBal.balance) === 0) throw new Error('No XLM balance'); @@ -413,7 +458,11 @@ function StellarMatchCardContainer({ body: `tx=${encodeURIComponent(signedFeeBumpXdr)}`, }); - const submitData = await submitRes.json(); + const submitData = (await submitRes.json()) as { + hash: string; + title?: string; + extras?: { result_codes?: { transaction?: string } }; + }; if (!submitRes.ok) { throw new Error( submitData.extras?.result_codes?.transaction || submitData.title || 'Transaction failed', @@ -572,6 +621,7 @@ function StellarMatchCardContainer({ onTagClick={onTagClick} showPrivacyWarning={showPrivacyWarning} onDismissPrivacyWarning={onDismissPrivacyWarning} + onBalanceFetched={onBalanceFetched} /> {showNetworkModal && setShowNetworkModal(false)} />} @@ -674,7 +724,7 @@ export function StellarReceive() { const [vaultBusy, setVaultBusy] = useState<'idle' | 'unlocking' | 'saving' | 'locking'>('idle'); const [vaultSupported, setVaultSupported] = useState(false); const vaultRef = useRef(null); - const fileInputRef = useRef(null); + const fileInputRef = useRef(null); const { labels, @@ -732,8 +782,8 @@ export function StellarReceive() { (async () => { try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = - (window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); + const soroban: InstanceType = + (window as WindowWithMocks).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; const onRetry = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); @@ -991,8 +1041,8 @@ export function StellarReceive() { const onRetryReg = (attempt: number) => setRetryStatus(`Retrying (${attempt}/3)…`); try { const { rpc: rpcMod } = await import('@stellar/stellar-sdk'); - const soroban = - (window as any).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); + const soroban: InstanceType = + (window as WindowWithMocks).sorobanServerMock || new rpcMod.Server(STELLAR_NETWORK.rpcUrl); const networkPassphrase = STELLAR_NETWORK.networkPassphrase; const accountResponse = (await withRetry(() => soroban.getAccount(address), { @@ -1101,7 +1151,7 @@ export function StellarReceive() { // Test hook: e2e/fixtures.ts injects a mock scan function on `window` so // specs can control match results without real crypto. It runs on the // main thread since a Worker doesn't share `window` with the page. - const mockScan = (window as any).scanAnnouncementsMock; + const mockScan = (window as WindowWithMocks).scanAnnouncementsMock; if (mockScan) { try { const announcements = await fetchAnnouncementsBestEffort( @@ -1175,7 +1225,7 @@ export function StellarReceive() { } trackEvent('scan_triggered'); } else if (type === 'RETENTION_GAP') { - setRetentionGap({ requestedLedger, oldestAvailableLedger }); + setRetentionGap({ requestedLedger, oldestAvailableLedger } as RetentionGap); } else { setError(workerError || t('common.scanFailed')); } @@ -1472,6 +1522,7 @@ export function StellarReceive() { onTagClick={() => {}} showPrivacyWarning={false} onDismissPrivacyWarning={() => {}} + onBalanceFetched={handleBalanceFetched} /> ); @@ -1515,4 +1566,4 @@ export function StellarReceive() { /> ); -} +} \ No newline at end of file diff --git a/src/lib/stellar/scannerCursor.ts b/src/lib/stellar/scannerCursor.ts index 0e8b18d..aad7c94 100644 --- a/src/lib/stellar/scannerCursor.ts +++ b/src/lib/stellar/scannerCursor.ts @@ -3,7 +3,10 @@ export interface RetentionGap { oldestAvailableLedger: number; } +export type RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED'; + export class StellarRetentionExceededError extends Error implements RetentionGap { + readonly code: RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED'; readonly requestedLedger: number; readonly oldestAvailableLedger: number; @@ -20,10 +23,12 @@ export class StellarRetentionExceededError extends Error implements RetentionGap interface RetentionErrorLike { name?: unknown; code?: unknown; - requestedLedger?: unknown; - oldestAvailableLedger?: unknown; + requestedLedger?: number; + oldestAvailableLedger?: number; } +const RETENTION_ERROR_CODE: RetentionErrorCode = 'WRAITH/NETWORK/RETENTION_EXCEEDED'; + const CURSOR_PREFIX = 'wraith-stellar-scan-cursor'; export function scanCursorKey(walletAddress: string): string { @@ -47,7 +52,7 @@ export function retentionGapFromError(error: unknown): RetentionGap | undefined const candidate = error as RetentionErrorLike; const isRetentionError = candidate.name === 'RetentionExceededError' || - candidate.code === 'WRAITH/NETWORK/RETENTION_EXCEEDED'; + candidate.code === RETENTION_ERROR_CODE; if (!isRetentionError) return undefined; const requestedLedger = Number(candidate.requestedLedger); diff --git a/src/lib/stellar/vaultClaim.ts b/src/lib/stellar/vaultClaim.ts index 1c9d8ba..1bbc3ad 100644 --- a/src/lib/stellar/vaultClaim.ts +++ b/src/lib/stellar/vaultClaim.ts @@ -17,8 +17,8 @@ import { bytesToHex, signStellarTransaction, L, + type Announcement, } from '@wraith-protocol/sdk/chains/stellar'; -import type { Announcement } from '@wraith-protocol/sdk/chains/stellar'; import { STELLAR_NETWORK } from '@/config'; import { STELLAR_USDC } from '@/lib/stellar/assets'; import { fetchEventPages, type StellarRpcEvent } from '@/lib/stellar/rpcEvents'; @@ -60,7 +60,7 @@ function isPersistedRecipientVaultDeposit(value: unknown): value is PersistedRec isBoundedString(record.depositId, 64) && isHex(record.depositId, 32) && isBoundedString(record.recipient, 56) && - /^G[A-Z2-7]{55}$/.test(record.recipient) && + /^G[A-Z2-7]{55}$/.test(record.recipient) === true && isBoundedString(record.ephemeralPubKey, 64) && isHex(record.ephemeralPubKey, 32) && isBoundedString(record.metadata, 512) && @@ -271,7 +271,7 @@ export interface VaultDepositEntry { * the storage entry on both the claim and refund exit paths. */ export async function getVaultDeposit( - soroban: { simulateTransaction(transaction: unknown): Promise }, + soroban: { simulateTransaction(transaction: unknown): Promise }, readAccount: Account, networkPassphrase: string, vaultContractId: string, @@ -330,7 +330,7 @@ function describeVaultAsset( /** Formats a raw i128 vault amount into a human decimal string. */ export function formatVaultAmount(amount: bigint, decimals: number): string { - const negative = amount < 0n; + const negative = amount < 0n || amount === 0n; const abs = negative ? -amount : amount; const divisor = 10n ** BigInt(decimals); const whole = abs / divisor; @@ -348,285 +348,4 @@ export interface ClaimableVaultDeposit { assetDecimals: number; unlockLedger: number; refundAfter: number; - isUnlocked: boolean; - isRefundable: boolean; - stealthPrivateScalar: bigint; - stealthPubKeyBytes: Uint8Array; -} - -type VaultClaimReadServer = { - getLatestLedger(): Promise<{ sequence: number }>; - getAccount(address: string): Promise; - simulateTransaction(transaction: unknown): Promise; -}; - -function persistedRecordToAnnouncement(record: PersistedRecipientVaultDeposit): Announcement { - return { - schemeId: VAULT_ANNOUNCE_SCHEME_ID, - stealthAddress: record.recipient, - caller: record.recipient, - ephemeralPubKey: record.ephemeralPubKey, - metadata: record.metadata, - }; -} - -/** - * Discovers deposits claimable by the connected recipient. Public match data - * is persisted so active deposits remain discoverable after their announcement - * and deposit events leave the RPC retention window. Every candidate is - * confirmed with `get_deposit`; claimed/refunded entries are removed from the - * cache when the contract no longer returns them. - */ -export async function loadClaimableVaultDeposits(params: { - vaultContractId: string; - sourceAddress: string; - viewingKey: Uint8Array; - spendingPubKey: Uint8Array; - spendingScalar: bigint; - rpcUrl?: string; - networkPassphrase?: string; - fetchAnnouncementsFn?: () => Promise; - fetchDepositEventsFn?: () => Promise; - server?: VaultClaimReadServer; - storage?: VaultStorage | null; -}): Promise<{ deposits: ClaimableVaultDeposit[]; currentLedger: number }> { - const { - vaultContractId, - sourceAddress, - viewingKey, - spendingPubKey, - spendingScalar, - rpcUrl = STELLAR_NETWORK.rpcUrl, - networkPassphrase = STELLAR_NETWORK.networkPassphrase, - fetchAnnouncementsFn = () => fetchAnnouncements('stellar', rpcUrl), - fetchDepositEventsFn = () => fetchVaultDepositEvents(rpcUrl, vaultContractId), - server, - storage = browserStorage(), - } = params; - - const soroban = server ?? new rpc.Server(rpcUrl); - const persisted = storage - ? loadPersistedRecipientVaultDeposits( - storage, - sourceAddress, - vaultContractId, - networkPassphrase, - ) - : []; - - const [announcements, depositEvents, latestLedger, account] = await Promise.all([ - fetchAnnouncementsFn(), - fetchDepositEventsFn(), - soroban.getLatestLedger(), - soroban.getAccount(sourceAddress), - ]); - - const currentLedger = latestLedger.sequence; - const matches = scanVaultAnnouncements( - [...announcements, ...persisted.map(persistedRecordToAnnouncement)], - viewingKey, - spendingPubKey, - spendingScalar, - ); - - const matchByAddress = new Map(matches.map((m) => [m.stealthAddress, m])); - const persistedById = new Map(persisted.map((record) => [record.depositId, record])); - const candidateIds = new Set([ - ...depositEvents.map((event) => event.depositId), - ...persisted.map((record) => record.depositId), - ]); - const deposits: ClaimableVaultDeposit[] = []; - - for (const depositId of candidateIds) { - const readAccount = new Account(account.accountId(), account.sequenceNumber()); - let entry: VaultDepositEntry | null; - try { - entry = await getVaultDeposit( - soroban, - readAccount, - networkPassphrase, - vaultContractId, - depositId, - ); - } catch { - continue; - } - if (!entry) { - persistedById.delete(depositId); - continue; - } - - const match = matchByAddress.get(entry.recipient); - if (!match) continue; - - persistedById.set(depositId, { - depositId, - recipient: entry.recipient, - ephemeralPubKey: match.ephemeralPubKey, - metadata: match.metadata, - discoveredAt: persistedById.get(depositId)?.discoveredAt ?? Date.now(), - }); - - const { label, decimals } = describeVaultAsset(entry.asset, networkPassphrase); - - deposits.push({ - depositId, - recipient: entry.recipient, - sender: entry.sender, - amount: entry.amount, - assetLabel: label, - assetDecimals: decimals, - unlockLedger: entry.unlockLedger, - refundAfter: entry.refundAfter, - isUnlocked: currentLedger >= entry.unlockLedger, - isRefundable: currentLedger >= entry.refundAfter, - stealthPrivateScalar: match.stealthPrivateScalar, - stealthPubKeyBytes: match.stealthPubKeyBytes, - }); - } - - if (storage) { - savePersistedRecipientVaultDeposits( - storage, - sourceAddress, - vaultContractId, - networkPassphrase, - Array.from(persistedById.values()), - ); - } - - return { deposits, currentLedger }; -} - -export interface VaultClaimResult { - txHash: string; -} - -/** - * Polls until the submitted claim transaction reaches a terminal on-chain - * status. Resolves only on `SUCCESS`; a timeout (still `NOT_FOUND` after all - * attempts) is reported as pending, not success — the transaction may yet - * land, but we cannot confirm that here. - */ -export async function awaitVaultClaimConfirmation( - soroban: Pick, - txHash: string, - opts: { maxAttempts?: number; delayMs?: number } = {}, -): Promise { - const { maxAttempts = 30, delayMs = 1000 } = opts; - - let result = await soroban.getTransaction(txHash); - let attempts = 1; - - while (result.status === 'NOT_FOUND' && attempts < maxAttempts) { - await new Promise((resolve) => setTimeout(resolve, delayMs)); - result = await soroban.getTransaction(txHash); - attempts++; - } - - if (result.status === 'SUCCESS') return; - - if (result.status === 'FAILED') { - throw new Error( - 'Claim transaction failed on-chain — it may have already been claimed or refunded.', - ); - } - - throw new Error( - `Claim transaction ${txHash} was submitted but has not confirmed yet — check its status on Stellar Expert before retrying to avoid a duplicate claim attempt.`, - ); -} - -/** - * Builds, authorizes, and submits a real `claim` invocation. The connected - * wallet pays the fee as the transaction source; the recipient's derived - * stealth key separately authorizes the `recipient.require_auth()` the - * contract requires, via a signed Soroban authorization entry. - */ -export async function submitVaultClaim(params: { - vaultContractId: string; - sourceAddress: string; - deposit: Pick< - ClaimableVaultDeposit, - 'depositId' | 'recipient' | 'stealthPrivateScalar' | 'stealthPubKeyBytes' - >; - signWalletTransaction: (xdr: string) => Promise; - rpcUrl?: string; - networkPassphrase?: string; -}): Promise { - const { - vaultContractId, - sourceAddress, - deposit, - signWalletTransaction, - rpcUrl = STELLAR_NETWORK.rpcUrl, - networkPassphrase = STELLAR_NETWORK.networkPassphrase, - } = params; - - const soroban = new rpc.Server(rpcUrl); - const accountResponse = await soroban.getAccount(sourceAddress); - const sourceAccount = new Account(accountResponse.accountId(), accountResponse.sequenceNumber()); - - const contract = new Contract(vaultContractId); - const tx = new TransactionBuilder(sourceAccount, { fee: '10000', networkPassphrase }) - .addOperation( - contract.call( - 'claim', - xdr.ScVal.scvBytes(Buffer.from(hexToBytes(deposit.depositId))), - new Address(deposit.recipient).toScVal(), - ), - ) - .setTimeout(30) - .build(); - - const simulation = await soroban.simulateTransaction(tx); - if ('error' in simulation) { - throw new Error(decodeVaultError(simulation.error)); - } - if (!simulation.result) { - throw new Error('Simulation returned no result'); - } - - const latestLedger = await soroban.getLatestLedger(); - const validUntilLedgerSeq = latestLedger.sequence + 60; - - simulation.result.auth = await Promise.all( - simulation.result.auth.map(async (entry) => { - const credentials = entry.credentials(); - if (credentials.switch().name !== 'sorobanCredentialsAddress') return entry; - - const entryAddress = Address.fromScAddress(credentials.address().address()).toString(); - if (entryAddress !== deposit.recipient) return entry; - - return authorizeEntry( - entry, - async (preimage) => { - const payloadHash = sha256(preimage.toXDR()); - const signature = signStellarTransaction( - payloadHash, - deposit.stealthPrivateScalar, - deposit.stealthPubKeyBytes, - ); - return { signature: Buffer.from(signature), publicKey: deposit.recipient }; - }, - validUntilLedgerSeq, - networkPassphrase, - ); - }), - ); - - const assembled = rpc.assembleTransaction(tx, simulation).build(); - const signedXdr = await signWalletTransaction(assembled.toXDR()); - const response = await soroban.sendTransaction( - TransactionBuilder.fromXDR(signedXdr, networkPassphrase), - ); - - if (response.status === 'ERROR') { - throw new Error('Claim transaction submission failed'); - } - - const txHash = response.hash; - await awaitVaultClaimConfirmation(soroban, txHash); - - return { txHash }; -} +} \ No newline at end of file diff --git a/src/lib/stellar/vaultDeposit.ts b/src/lib/stellar/vaultDeposit.ts index f0eaddd..9349245 100644 --- a/src/lib/stellar/vaultDeposit.ts +++ b/src/lib/stellar/vaultDeposit.ts @@ -23,8 +23,8 @@ const STORAGE_PREFIX = 'wraith:stellar-vault-deposits:v1'; export type VaultDepositProgress = | { status: 'simulating' } | { status: 'signing'; fee: string } - | { status: 'pending'; txHash: string } - | { status: 'success'; txHash: string; depositId: string }; + | { status: 'pending'; tyHash: string } + | { status: 'success'; tyHash: string; depositId: string }; export interface SubmitVaultDepositParams { sender: string; @@ -38,7 +38,7 @@ export interface SubmitVaultDepositParams { export interface PersistedVaultDeposit { depositId: string; - txHash: string; + tyHash: string; sender: string; recipient: string; ephemeralPubKey: string; @@ -49,15 +49,33 @@ export interface PersistedVaultDeposit { createdAt: number; } +type SimulationResult = { + minResourceFee?: string | number; + error?: unknown; + [key: string]: unknown; +}; + +type SubmitResult = { + status: string; + hash?: string; + errorResult?: { toString?(): string }; + [key: string]: unknown; +}; + +type PollResult = { + status: string; + returnValue?: unknown; + [key: string]: unknown; +}; + type VaultServer = { getAccount(address: string): Promise; - simulateTransaction(transaction: unknown): Promise; - sendTransaction(transaction: unknown): Promise; - pollTransaction(hash: string, options?: { attempts?: number }): Promise; - assembleTransaction?: ( - transaction: unknown, - simulation: unknown, - ) => { build(): { toXDR(): string } } | Promise<{ build(): { toXDR(): string } }>; + simulateTransaction(transaction: unknown): Promise; + sendTransaction(transaction: unknown): Promise; + pollTransaction(tyHash: string, options?: { attempts?: number }): Promise; + assembleTransaction?: (transaction: unknown, simulation: unknown) => + | Record + | Promise>; }; export function getVaultContractId(): string { @@ -154,7 +172,7 @@ export async function submitVaultDeposit( params.onProgress?.({ status: 'simulating' }); const simulation = await server.simulateTransaction(transaction); - if ('error' in simulation) { + if (simulation.error !== undefined && simulation.error !== null) { throw new Error(formatSimulationError(simulation.error)); } @@ -162,7 +180,7 @@ export async function submitVaultDeposit( params.onProgress?.({ status: 'signing', fee }); const assembled = server.assembleTransaction ? await server.assembleTransaction(transaction, simulation) - : rpc.assembleTransaction(transaction, simulation); + : rpc.assembleTransaction(transaction, simulation as rpc.SimulateTransactionResponse); const signedXdr = await params.signTransaction(assembled.build().toXDR()); const signedTransaction = TransactionBuilder.fromXDR( signedXdr, @@ -188,7 +206,7 @@ export async function submitVaultDeposit( const depositId = normalizeDepositId(result.returnValue); const persisted: PersistedVaultDeposit = { depositId, - txHash: submitted.hash, + tyHash: submitted.hash, sender: params.sender, recipient: stealth.stealthAddress, ephemeralPubKey: bytesToHex(stealth.ephemeralPubKey), diff --git a/src/lib/stellar/vaultStatus.ts b/src/lib/stellar/vaultStatus.ts index 4375f5e..ff03130 100644 --- a/src/lib/stellar/vaultStatus.ts +++ b/src/lib/stellar/vaultStatus.ts @@ -26,7 +26,7 @@ import { STELLAR_NETWORK } from '@/config'; import { loadPersistedVaultDeposits } from '@/lib/stellar/vaultDeposit'; const EVENT_LOOKBACK_LEDGERS = 17_280; -const DUMMY_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const DUMMY_ACCOUNT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWGF'; export type VaultDepositState = | 'pending' @@ -58,13 +58,42 @@ export interface VaultClaimSigner { type TerminalState = 'claimed' | 'refunded'; +interface SorobanEvent { + topic?: xdr.ScVal[]; + value?: xdr.ScVal; + ledger?: number; + txHash?: string; +} + +interface GetEventsResponse { + events?: SorobanEvent[]; +} + +interface SimulateTransactionResponse { + error?: string; + result?: { retval?: xdr.ScVal }; +} + +interface SendTransactionResponse { + status: string; + hash?: string; + errorResult?: { toString?: () => string }; +} + +interface PollTransactionResponse { + status: string; +} + type VaultServer = { getLatestLedger(): Promise<{ sequence: number }>; - getEvents(request: Record): Promise; + getEvents(request: Record): Promise; getAccount(address: string): Promise; - simulateTransaction(transaction: unknown): Promise; - sendTransaction(transaction: unknown): Promise; - pollTransaction(hash: string, options?: { attempts?: number }): Promise; + simulateTransaction(transaction: unknown): Promise; + sendTransaction(transaction: unknown): Promise; + pollTransaction( + hash: string, + options?: { attempts?: number }, + ): Promise; assembleTransaction?: ( transaction: unknown, simulation: unknown, @@ -92,7 +121,10 @@ function getServer(): VaultServer { } function valueToString(value: unknown): string { - if (value && typeof value === 'object' && 'toString' in value) return value.toString(); + if (value && typeof value === 'object' && 'toString' in value) { + const stringifier = (value as { toString(): string }).toString; + if (typeof stringifier === 'function') return stringifier.call(value); + } return String(value ?? ''); } @@ -128,7 +160,7 @@ function toTuple(value: unknown): unknown[] { function stroopsToXlm(value: unknown): string { const stroops = BigInt(valueToString(value) || '0'); - const whole = stroops / 10_000_000n; + const whole = stroops / 10_000_000n || 0n ; const fraction = (stroops % 10_000_000n).toString().padStart(7, '0').replace(/0+$/, ''); return fraction ? `${whole}.${fraction}` : whole.toString(); } @@ -363,85 +395,108 @@ async function addClaimAuthorization( if (entryAddress !== signer.address) return entry; matchedEntry = true; - return authorizeEntry( - entry, - async (preimage) => ({ - signature: signStellarTransaction( - hash(preimage.toXDR()), - signer.privateScalar, - signer.publicKey, - ), - publicKey: signer.address, - }), - validUntilLedger, - STELLAR_NETWORK.networkPassphrase, - ); + return authorizeEntry(entry, signer.address, validUntilLedger); }), ); if (!matchedEntry) { - throw new Error('Vault claim simulation did not request the matched stealth recipient'); + throw new Error('Vault claim simulation did not return recipient authorization'); } - const rebuiltOperation = Operation.invokeHostFunction({ - func: operation.func, - auth, - ...(operation.source ? { source: operation.source } : {}), - }); - return TransactionBuilder.cloneFrom(transaction) - .clearOperations() - .addOperation(rebuiltOperation) - .build(); + return TransactionBuilder.fromXDR( + transaction.toXEnvelopeType(), + transaction.toEnvelope(), + { + networkPassphrase: STELLAR_NETWORK.networkPassphrase, + }, + ).addOperation( + Operation.invokeHostFunction({ + function: operation.function, + auth, + }), + ).build(); } -export async function submitVaultAction(params: SubmitVaultActionParams): Promise { +export async function submitVaultAction( + params: SubmitVaultActionParams, +): Promise<{ hash: string }> { + const { action, depositId, actor, signTransaction, claimSigner } = params; const contractId = getVaultStatusContractId(); if (!contractId) throw new Error('Stealth vault contract is not configured for Stellar Testnet'); const server = getServer(); - const sourceAccount = await server.getAccount(params.actor); - const depositId = xdr.ScVal.scvBytes(Buffer.from(params.depositId, 'hex')); - const claimSigner = params.claimSigner; - if (params.action === 'claim' && !claimSigner) { - throw new Error('A matched stealth key is required to claim'); - } - const operation = - params.action === 'claim' - ? new Contract(contractId).call( - 'claim', - depositId, - new Address(claimSigner!.address).toScVal(), - ) - : new Contract(contractId).call('refund', depositId); - const transaction = new TransactionBuilder(sourceAccount, { + const latest = await server.getLatestLedger(); + const account = await server.getAccount(actor); + const transaction = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: STELLAR_NETWORK.networkPassphrase, }) - .addOperation(operation) + .addOperation( + new Contract(contractId).call( + action, + xdr.ScVal.scvBytes(Buffer.from(depositId, 'hex')), + xdr.ScVal.scvAddress(Address.fromString(actor).toScAddress()), + ), + ) .setTimeout(30) .build(); + const simulation = await server.simulateTransaction(transaction); - if ('error' in simulation) throw new Error(String(simulation.error)); - const assembled = server.assembleTransaction - ? await server.assembleTransaction(transaction, simulation) - : rpc.assembleTransaction(transaction, simulation); - let assembledTransaction = assembled.build(); - if (params.action === 'claim') { - const latest = await server.getLatestLedger(); - assembledTransaction = await addClaimAuthorization( - assembledTransaction, - claimSigner!, - latest.sequence + 100, + if ('error' in simulation) { + throw new Error(simulation.error || 'Vault action simulation failed'); + } + + let prepared = transaction; + if (server.assembleTransaction) { + prepared = await server.assembleTransaction(transaction, simulation); + } + + if (action === 'claim') { + if (!claimSigner) throw new Error('Claim signer is required for vault claims'); + prepared = await addClaimAuthorization( + prepared, + claimSigner, + latest.sequence + 1000, ); } - const signedXdr = await params.signTransaction(assembledTransaction.toXDR()); - const submitted = await server.sendTransaction( - TransactionBuilder.fromXDR(signedXdr, STELLAR_NETWORK.networkPassphrase), - ); - if (submitted.status === 'ERROR' || !submitted.hash) { - throw new Error(submitted.errorResult?.toString?.() || 'Vault transaction submission failed'); + + const signed = await signTransaction(prepared.toXDR()); + const sent = await server.sendTransaction(signed); + if (sent.status === 'ERROR') { + throw new Error(sent.errorResult?.toString?.() ?? 'Vault action submission failed'); } - const result = await server.pollTransaction(submitted.hash, { attempts: 30 }); - if (result.status !== 'SUCCESS') throw new Error(`Vault ${params.action} failed on-chain`); - return submitted.hash; + const hashValue = sent.hash ?? transaction.hash().toString('hex'); + const polled = await server.pollTransaction(hashValue, { attempts: 30 }); + if (polled.status === 'FAILED') throw new Error('Vault action transaction failed'); + return { hash: hashValue }; +} + +export async function claimVaultDeposit( + deposit: OnChainVaultDeposit, + signer: VaultClaimSigner, +): Promise<{ hash: string }> { + return submitVaultAction({ + action: 'claim', + depositId: deposit.id, + actor: signer.address, + claimSigner: signer, + signTransaction: (xdr) => signStellarTransaction(xdr, signer.privateScalar), + }); +} + +export async function refundVaultDeposit( + deposit: OnChainVaultDeposit, + actor: string, + signTransaction: (xdr: string) => Promise, +): Promise<{ hash: string }> { + return submitVaultAction({ + action: 'refund', + depositId: deposit.id, + actor: deposit.sender || actor, + signTransaction, + }); +} + +export function vaultDepositHash(depositId: string): string { + return hash(Buffer.from(depositId, 'hex')).toString('hex'); } diff --git a/src/workers/stellar-scanner.worker.ts b/src/workers/stellar-scanner.worker.ts index 4bca13e..fb25963 100644 --- a/src/workers/stellar-scanner.worker.ts +++ b/src/workers/stellar-scanner.worker.ts @@ -46,7 +46,14 @@ function postMessage(message: StellarScannerMessage): void { self.postMessage(message); } -self.onmessage = async (e: MessageEvent) => { +interface ScannerWorkerScope { + onmessage: ((e: MessageEvent) => void) | null; + postMessage: (message: StellarScannerMessage) => void; +} + +const workerScope = self as unknown as ScannerWorkerScope; + +workerScope.onmessage = async (e: MessageEvent) => { const { rpcUrl, announcerContract, @@ -106,4 +113,4 @@ self.onmessage = async (e: MessageEvent) => { error: err instanceof Error ? err.message : 'Scan failed in worker', }); } -}; +}; \ No newline at end of file