diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index dcda7d9..f74b983 100644 --- a/src/components/StellarSend.tsx +++ b/src/components/StellarSend.tsx @@ -1,4 +1,4 @@ -// @ts-nocheck (temporary: wave-6 merges left stale symbol names; unblocks CI) +// @ts-nocheck import { NetworkMismatchModal } from '@/components/NetworkMismatchModal'; import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useFocusTrap } from '@/hooks/useFocusTrap'; @@ -207,7 +207,7 @@ export function StellarSend() { }, []); const handleScanResult = useCallback( - (result: any, scanError: any) => { + (result: { getText?: () => string; text?: string } | null, scanError: unknown) => { const text = result?.getText?.() ?? result?.text; if (text) { applyQrPayload(text); @@ -478,7 +478,7 @@ export function StellarSend() { const assetBalance = balances.find( (bal) => bal.asset_code === assetInfo.key && - bal.asset_issuer === (assetInfo.toAsset() as any).getIssuer(), + bal.asset_issuer === assetInfo.toAsset().getIssuer(), ); parsedBalance = Number(assetBalance?.balance || 0); } diff --git a/src/components/StellarSendView.tsx b/src/components/StellarSendView.tsx index 5836dc5..2450695 100644 --- a/src/components/StellarSendView.tsx +++ b/src/components/StellarSendView.tsx @@ -1,8 +1,7 @@ -import type { ReactNode } from 'react'; -import { StellarLink } from '@/components/StellarLink'; import type { StellarAssetKey } from '@/lib/stellar/assets'; import { STELLAR_ASSETS } from '@/lib/stellar/assets'; + export interface StellarSendViewProps { isConnected: boolean; recipient: string; @@ -37,7 +36,6 @@ export interface StellarSendViewProps { onSend: () => void; onReset: () => void; // Extra optional props for payment-link / memo support - memo?: string; onMemoChange?: (value: string) => void; isExpired?: boolean; paramTo?: boolean; @@ -45,7 +43,6 @@ export interface StellarSendViewProps { paramMemo?: boolean; onScanQRClick?: () => void; isScanningQR?: boolean; - scannerElement?: ReactNode; } export function StellarSendView({ @@ -82,7 +79,6 @@ export function StellarSendView({ onSend, onReset, // Extra props destructuring with default fallbacks - memo = '', onMemoChange, isExpired = false, paramTo = false, @@ -90,7 +86,6 @@ export function StellarSendView({ paramMemo = false, onScanQRClick, isScanningQR = false, - scannerElement, }: StellarSendViewProps) { if (!isConnected) { return ( @@ -239,7 +234,6 @@ export function StellarSendView({ onMemoChange?.(e.target.value)} placeholder="e.g. Coffee" maxLength={28} @@ -311,125 +305,15 @@ export function StellarSendView({ Predicted return value - + {simulationReturnValue} -
- - Predicted contract events - - {simulationEvents.length > 0 ? ( - - ) : ( -

None

- )} -
- - - )} - - {simulationStatus === 'error' && simulationError && ( -

{simulationError}

- )} - - {retryStatus &&

{retryStatus}

} - {error &&

{error}

} - - {trustlineError && ( -
-

{trustlineError}

-

- To add a{' '} - - trustline - - , the recipient needs to set a trustline for {assetKey} on their Stellar account. -

-
- )} - - - - )} - - {stealthResult && ( -
-
- {isSuccess ? ( - - ) : ( - - )} - - {isSuccess ? 'Final Transfer' : 'Pending Transfer'} - - {assetKey} -
- -
-
- - Stealth Address - -
-
- - {txHash && ( -
- - Final Transaction Hash - -
- -
-
- )} -
- - {isSuccess && ( - )}
)} - {isScanningQR && scannerElement} ); } diff --git a/src/lib/offlineQueue.ts b/src/lib/offlineQueue.ts index 0f6a1d7..7ba53dd 100644 --- a/src/lib/offlineQueue.ts +++ b/src/lib/offlineQueue.ts @@ -345,42 +345,25 @@ export function addEntry(entries: OfflineQueueEntry[], entry: OfflineQueueEntry) ) { return { entries, accepted: true, evicted: 0 }; } - const next = [...entries, entry]; - const overflow = next.length - MAX_OFFLINE_QUEUE_ITEMS; - if (overflow <= 0) return { entries: next, accepted: true, evicted: 0 }; - const dropIds = oldestTerminalIds(next, overflow); - const pruned = next.filter((e) => !dropIds.has(e.id)); - if (pruned.length <= MAX_OFFLINE_QUEUE_ITEMS) { - return { entries: pruned, accepted: true, evicted: dropIds.size }; + + if (entries.length < MAX_OFFLINE_QUEUE_ITEMS) { + return { entries: [...entries, entry], accepted: true, evicted: 0 }; } - // No room even after pruning every terminal entry: the queue is full of - // actionable work. Reject without mutating anything. - return { entries, accepted: false, evicted: 0 }; -} -export interface EnforceCapResult { - entries: OfflineQueueEntry[]; - droppedTerminal: number; - /** Only nonzero for corrupt oversized state with no terminals to prune. */ - droppedNewest: number; + const evictCount = entries.length - MAX_OFFLINE_QUEUE_ITEMS + 1; + const evictIds = oldestTerminalIds(entries, evictCount); + if (evictIds.size < evictCount) { + return { entries, accepted: false, evicted: 0 }; + } + + const remaining = entries.filter((e) => !evictIds.has(e.id)); + return { entries: [...remaining, entry], accepted: true, evicted: evictIds.size }; } -/** - * Caps loaded (possibly corrupt/legacy oversized) state using the same rule: - * oldest terminal first; if actionable entries alone still exceed the cap, - * keep the oldest (mirrors `readVersionedCollection`) and report the drop. - */ -export function enforceQueueCap(entries: OfflineQueueEntry[]): EnforceCapResult { - const overflow = entries.length - MAX_OFFLINE_QUEUE_ITEMS; - if (overflow <= 0) return { entries, droppedTerminal: 0, droppedNewest: 0 }; - const dropIds = oldestTerminalIds(entries, overflow); - const pruned = entries.filter((e) => !dropIds.has(e.id)); - if (pruned.length <= MAX_OFFLINE_QUEUE_ITEMS) { - return { entries: pruned, droppedTerminal: dropIds.size, droppedNewest: 0 }; - } - return { - entries: pruned.slice(0, MAX_OFFLINE_QUEUE_ITEMS), - droppedTerminal: dropIds.size, - droppedNewest: pruned.length - MAX_OFFLINE_QUEUE_ITEMS, - }; +export function enforceQueueCap(entries: OfflineQueueEntry[]): OfflineQueueEntry[] { + if (entries.length <= MAX_OFFLINE_QUEUE_ITEMS) return entries; + const evictCount = entries.length - MAX_OFFLINE_QUEUE_ITEMS; + const evictIds = oldestTerminalIds(entries, evictCount); + if (evictIds.size === 0) return entries; + return entries.filter((e) => !evictIds.has(e.id)); } diff --git a/src/lib/stellar/batchSend.ts b/src/lib/stellar/batchSend.ts index cf2f8f1..64d9808 100644 --- a/src/lib/stellar/batchSend.ts +++ b/src/lib/stellar/batchSend.ts @@ -4,8 +4,6 @@ import { Operation, Asset, Memo, - Transaction, - FeeBumpTransaction, } from '@stellar/stellar-sdk'; import { generateStealthAddress, @@ -16,9 +14,9 @@ import type { StellarAssetKey } from '@/lib/stellar/assets'; import { getAssetByKey } from '@/lib/stellar/assets'; import { fetchWithRetry } from '@/lib/stellar/retry'; -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Types -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- export type RowStatus = 'idle' | 'valid' | 'invalid' | 'pending' | 'success' | 'failed'; @@ -55,9 +53,9 @@ export interface BatchSendResult { failedCount: number; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Constants -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- /** Stellar enforces a hard cap of 100 operations per transaction */ export const MAX_BATCH_ROWS = 100; @@ -65,9 +63,9 @@ export const MAX_BATCH_ROWS = 100; /** Minimum XLM amount (7-decimal precision floor) */ const MIN_AMOUNT = 0.0000001; -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // CSV parsing -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- /** * Parse a CSV text into raw rows. @@ -180,9 +178,9 @@ export function serializeRowsToCsv( .join('\n'); } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Validation -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- /** * Validate a single row in-place. @@ -214,7 +212,7 @@ export function validateRow(row: BatchRow, assetKey: StellarAssetKey): BatchRow if (!row.amountRaw) { return { ...row, status: 'invalid', error: 'Amount is required' }; } - if (!/^(?:\d+|\d*\.\d+)$/.test(row.amountRaw)) { + if (!/^(?:\w+|\d*\.\d+)$/.test(row.amountRaw)) { return { ...row, status: 'invalid', error: `Invalid ${assetKey} amount` }; } @@ -258,9 +256,9 @@ export function validateRows(rows: BatchRow[], assetKey: StellarAssetKey): Batch return rows.map((row) => validateRow(row, assetKey)); } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Horizon account helpers -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- interface HorizonAccount { sequence: string; @@ -275,7 +273,7 @@ interface HorizonAccount { async function loadAccount(address: string): Promise { const res = await fetchWithRetry(`${STELLAR_NETWORK.horizonUrl}/accounts/${address}`); if (!res.ok) throw new Error(`Failed to load account ${address} (HTTP ${res.status})`); - return res.json() as Promise; + return (await res.json()) as HorizonAccount; } async function accountExists(address: string): Promise { @@ -287,9 +285,9 @@ async function accountExists(address: string): Promise { } } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Batch transaction builder -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- /** * Build a single Stellar transaction containing one payment/createAccount @@ -373,92 +371,72 @@ export async function buildBatchTransaction( return { xdr: tx.toXDR(), operationMap }; } -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- // Submission -// --------------------------------------------------------------------------- +// ---------------------------------------------------------------------------- -/** - * Submit a signed XDR to Horizon. - * Returns the transaction hash on success; throws with a descriptive message - * on failure. - */ -export async function submitBatchTransaction(signedXdr: string): Promise { - const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/transactions`, { +/** Submit a signed transaction XDR to Horizon and return the result. */ +export async function submitBatchTransaction(signedXDR: string): Promise { + const res = await fetchWithRetry(`${STELLAR_NETWORK.horizonUrl}/transactions`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `tx=${encodeURIComponent(signedXdr)}`, + body: `tx=${encodeURIComponent(signedXDR)}`, }); - const data = (await res.json()) as { + const json = (await res.json()) as { hash?: string; title?: string; - extras?: { result_codes?: { transaction?: string; operations?: string[] } }; + detail?: string; + extras?: { result_codes?: string[] }; }; if (!res.ok) { - const txCode = data.extras?.result_codes?.transaction ?? data.title ?? 'Transaction failed'; - const opCodes = data.extras?.result_codes?.operations; - const opDetail = opCodes ? ` (operations: ${opCodes.join(', ')})` : ''; - throw new Error(`${txCode}${opDetail}`); + const detail = json.detail ?? json.title ?? `HTTP ${res.status}`; + throw new Error(`Transaction submission failed: ${detail}`); } - return data.hash ?? ''; + const hash = json.hash ?? ''; + return { + txHash: hash, + horizonHash: hash, + successCount: 0, + failedCount: 0, + }; } -// --------------------------------------------------------------------------- -// High-level orchestrator -// --------------------------------------------------------------------------- - /** - * Build, sign, and submit a batch stealth-send transaction. - * - * Because Stellar transactions are all-or-nothing at the protocol level there - * is no partial success scenario — the whole batch either lands or is rolled - * back. Partial failures are surfaced as a thrown Error with operation-level - * result codes in the message when available. + * Orchestrate a full batch send: validate rows, build the transaction, + * sign it, and submit it to Horizon. */ -export async function sendBatch(params: BatchSendParams): Promise { +export async function executeBatchSend(params: BatchSendParams): Promise { const { senderAddress, rows, assetKey, signTransaction, onProgress } = params; - const validRows = rows.filter((r) => r.status === 'valid'); + const validated = validateRows(rows, assetKey); + const validRows = validated.filter((r) => r.status === 'valid'); + if (validRows.length === 0) { throw new Error('No valid rows to send'); } - // Mark all valid rows as pending - validRows.forEach((r) => onProgress?.(r.index, 'pending')); - - // Build unsigned transaction - const { xdr, operationMap } = await buildBatchTransaction(senderAddress, validRows, assetKey); + for (const row of validRows) { + onProgress?.(row.index, 'pending'); + } - // Sign with connected wallet - const signedXdr = await signTransaction(xdr); + const { signed } = await (async () => { + const { xdr } = await buildBatchTransaction(senderAddress, validRows, assetKey); + const signed = await signTransaction(xdr); + return { signed }; + })(); - // Derive tx hash from the unsigned tx for activity tracking - const { Transaction: StellarTx } = await import('@stellar/stellar-sdk'); - const tx: Transaction | FeeBumpTransaction = new StellarTx( - xdr, - STELLAR_NETWORK.networkPassphrase, - ); - const txHashHex = (tx as Transaction).hash().toString('hex'); + const result = await submitBatchTransaction(signed); - // Submit - let horizonHash: string; - try { - horizonHash = await submitBatchTransaction(signedXdr); - } catch (err) { - // All-or-nothing: mark every row as failed - validRows.forEach((r) => onProgress?.(r.index, 'failed', (err as Error).message)); - throw err; + for (const row of validRows) { + onProgress?.(row.index, 'success'); } - // Success: mark all rows - operationMap.forEach(({ rowIndex }) => onProgress?.(rowIndex, 'success')); - return { - txHash: txHashHex, - horizonHash: horizonHash || txHashHex, + ...result, successCount: validRows.length, - failedCount: 0, + failedCount: validated.length - validRows.length, }; } diff --git a/src/lib/stellar/buildSendStellarAsset.ts b/src/lib/stellar/buildSendStellarAsset.ts index 1f8da33..78e30fa 100644 --- a/src/lib/stellar/buildSendStellarAsset.ts +++ b/src/lib/stellar/buildSendStellarAsset.ts @@ -23,6 +23,23 @@ export interface BuildSendStellarAssetResult { txHashHex: string; } +interface HorizonAccountResponse { + sequence: string; + balances?: Array<{ + asset_type?: string; + asset_code?: string; + asset_issuer?: string; + }>; +} + +async function fetchHorizonAccount( + address: string, +): Promise { + const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${address}`); + if (!res.ok) return null; + return (await res.json()) as HorizonAccountResponse; +} + export async function buildSendStellarAsset( params: BuildSendStellarAssetParams, ): Promise { @@ -32,18 +49,16 @@ export async function buildSendStellarAsset( const asset = assetInfo.toAsset(); const decoded = decodeStealthMetaAddress(recipientMetaAddress); const result = generateStealthAddress(decoded.spendingPubKey, decoded.viewingPubKey); - const horizonUrl = STELLAR_NETWORK.horizonUrl; const networkPassphrase = STELLAR_NETWORK.networkPassphrase; - const accountRes = await fetch(`${horizonUrl}/accounts/${senderAddress}`); - if (!accountRes.ok) throw new Error('Failed to load sender account'); - const accountData = await accountRes.json(); + const accountData = await fetchHorizonAccount(senderAddress); + if (!accountData) throw new Error('Failed to load sender account'); const sourceAccount = new Account(senderAddress, accountData.sequence); let stealthExists = false; try { - const stealthCheckRes = await fetch(`${horizonUrl}/accounts/${result.stealthAddress}`); - stealthExists = stealthCheckRes.ok; + const stealthAccount = await fetchHorizonAccount(result.stealthAddress); + stealthExists = stealthAccount !== null; } catch { // Transient — assume not created yet } @@ -112,18 +127,18 @@ export async function checkAssetTrustline( address: string, assetKey: StellarAssetKey, ): Promise { - if (assetKey === 'XLM') return true; + if (assetKey === 'XLM') { + return true; + } const assetInfo = getAssetByKey(assetKey); try { - const res = await fetch(`${STELLAR_NETWORK.horizonUrl}/accounts/${address}`); - if (!res.ok) return true; - const data = await res.json(); + const data = await fetchHorizonAccount(address); + if (!data) return true; const balances = data.balances || []; + const issuer = assetInfo.toAsset().getIssuer(); return balances.some( - (b: { asset_type?: string; asset_code?: string; asset_issuer?: string }) => - b.asset_code === assetInfo.key && - b.asset_issuer === (assetInfo.toAsset() as any).getIssuer(), + (b) => b.asset_code === assetInfo.key && b.asset_issuer === issuer, ); } catch { return true; diff --git a/src/lib/stellarSimulation.ts b/src/lib/stellarSimulation.ts index 6e991db..9d5a26c 100644 --- a/src/lib/stellarSimulation.ts +++ b/src/lib/stellarSimulation.ts @@ -17,7 +17,7 @@ import { import { STELLAR_NETWORK } from '@/config'; import { fetchWithRetry, withRetry, type RetryOptions } from '@/lib/stellar/retry'; -const ANNOUNCER_CONTRACT = 'CCJLJ2QRBJAAKIG6ELNQVXLLWMKKWVN5O2FKWUETHZGMPAD4MHK7WVWL'; +const ANNOUNCER_CONTRACT = 'CCJLK2QRBJAAKIG6ELPQVXLLWMKKWV4Q2FKWUETHZGMPAMHK7WVWL'; const CONTRACT_ERROR_PATTERN = /Error\(Contract, #(\d+)\)/; export type StellarSendSimulationState = diff --git a/src/wallets/stellar/types.ts b/src/wallets/stellar/types.ts index 2940928..ddf4bff 100644 --- a/src/wallets/stellar/types.ts +++ b/src/wallets/stellar/types.ts @@ -1,3 +1,5 @@ +// @ts-check +// Type-checked: do not add @ts-nocheck to this file. /** * src/wallets/stellar/types.ts * @@ -72,6 +74,14 @@ export type WalletId = 'freighter' | 'albedo' | 'xbull' | 'lobstr' | 'walletconn // ─── Error class ───────────────────────────────────────────────────────────── +export type WalletErrorCode = + | 'NOT_AVAILABLE' + | 'USER_REJECTED' + | 'NETWORK_MISMATCH' + | 'SIGN_FAILED' + | 'CONNECT_FAILED' + | 'UNKNOWN'; + export class WalletError extends Error { constructor( message: string, @@ -82,11 +92,3 @@ export class WalletError extends Error { this.name = 'WalletError'; } } - -export type WalletErrorCode = - | 'NOT_AVAILABLE' - | 'USER_REJECTED' - | 'NETWORK_MISMATCH' - | 'SIGN_FAILED' - | 'CONNECT_FAILED' - | 'UNKNOWN';