diff --git a/src/components/StellarSend.tsx b/src/components/StellarSend.tsx index b5a72bb..3e9767f 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'; @@ -218,7 +218,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); @@ -491,7 +491,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..b81e489 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,12 +86,13 @@ export function StellarSendView({ paramMemo = false, onScanQRClick, isScanningQR = false, - scannerElement, }: StellarSendViewProps) { if (!isConnected) { return (
- + Stellar Testnet / {assetKey}

@@ -237,9 +234,9 @@ export function StellarSendView({ Memo (optional) onMemoChange?.(e.target.value)} placeholder="e.g. Coffee" maxLength={28} @@ -311,125 +308,15 @@ export function StellarSendView({ Predicted return value - + {simulationReturnValue} -
- - Predicted contract events - - {simulationEvents.length > 0 ? ( -
    - {simulationEvents.map((event, index) => ( -
  • - {event} -
  • - ))} -
- ) : ( -

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 e2ee022..54882a8 100644 --- a/src/lib/offlineQueue.ts +++ b/src/lib/offlineQueue.ts @@ -334,78 +334,7 @@ function oldestTerminalIds(entries: OfflineQueueEntry[], count: number): Set isTerminalStatus(e.status)) .sort((a, b) => a.updatedAt - b.updatedAt) - .slice(0, count) + .slice(0, Math.max(0, count)) .map((e) => e.id), ); } - -/** - * Adds an entry, enforcing dedupe (by id, and by txHash for signed - * envelopes) and the collection cap. - * - * Full-but-actionable queues reject explicitly instead of dropping work: - * with 50 actionable entries, the 51st returns `{ accepted: false }` and the - * input list untouched — never 51 items, never a silent tail-drop at save. - */ -export function addEntry(entries: OfflineQueueEntry[], entry: OfflineQueueEntry): AddEntryResult { - if (entries.some((e) => e.id === entry.id)) return { entries, accepted: true, evicted: 0 }; - if ( - (entry.kind === 'payment-intent' || entry.kind === 'signed-submit') && - entries.some( - (e) => - e.kind === entry.kind && - (e.payload as PaymentIntentPayload | SignedSubmitPayload).operationId === - (entry.payload as PaymentIntentPayload | SignedSubmitPayload).operationId, - ) - ) { - return { entries, accepted: true, evicted: 0 }; - } - if ( - entry.kind === 'signed-submit' && - entries.some( - (e) => - e.kind === 'signed-submit' && - (e.payload as SignedSubmitPayload).txHash === (entry.payload as SignedSubmitPayload).txHash, - ) - ) { - 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 }; - } - // 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; -} - -/** - * 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, - }; -} diff --git a/src/lib/stellar/batchSend.ts b/src/lib/stellar/batchSend.ts index cf2f8f1..57e90f1 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 @@ -372,93 +370,3 @@ export async function buildBatchTransaction( const tx = builder.build(); 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`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `tx=${encodeURIComponent(signedXdr)}`, - }); - - const data = (await res.json()) as { - hash?: string; - title?: string; - extras?: { result_codes?: { transaction?: string; operations?: 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}`); - } - - return data.hash ?? ''; -} - -// --------------------------------------------------------------------------- -// 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. - */ -export async function sendBatch(params: BatchSendParams): Promise { - const { senderAddress, rows, assetKey, signTransaction, onProgress } = params; - - const validRows = rows.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); - - // Sign with connected wallet - const signedXdr = await signTransaction(xdr); - - // 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'); - - // 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; - } - - // Success: mark all rows - operationMap.forEach(({ rowIndex }) => onProgress?.(rowIndex, 'success')); - - return { - txHash: txHashHex, - horizonHash: horizonHash || txHashHex, - successCount: validRows.length, - failedCount: 0, - }; -} diff --git a/src/lib/stellar/buildSendStellarAsset.ts b/src/lib/stellar/buildSendStellarAsset.ts index 1f8da33..b617c08 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 { @@ -31,19 +48,20 @@ export async function buildSendStellarAsset( const assetInfo = getAssetByKey(assetKey); const asset = assetInfo.toAsset(); const decoded = decodeStealthMetaAddress(recipientMetaAddress); - const result = generateStealthAddress(decoded.spendingPubKey, decoded.viewingPubKey); - const horizonUrl = STELLAR_NETWORK.horizonUrl; + const result = generateStealthAddress( + decoded.spendingPubKey, + decoded.viewingPubKey, + ); 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 +130,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 7c394b1..7a05c1a 100644 --- a/src/lib/stellarSimulation.ts +++ b/src/lib/stellarSimulation.ts @@ -18,6 +18,7 @@ import { STELLAR_NETWORK } from '@/config'; import { getStellarContractId } from '@/lib/stellar/deployments'; import { fetchWithRetry, withRetry, type RetryOptions } from '@/lib/stellar/retry'; +const ANNOUNCER_CONTRACT = 'CCJLK2QRBJAAKIG6ELPQVXLLWMKKWV4Q2FKWUETHZGMPAMHK7WVWL'; const CONTRACT_ERROR_PATTERN = /Error\(Contract, #(\d+)\)/; export type StellarSendSimulationState = diff --git a/src/stores/offlineQueueStore.ts b/src/stores/offlineQueueStore.ts index 5b79e16..77ebd17 100644 --- a/src/stores/offlineQueueStore.ts +++ b/src/stores/offlineQueueStore.ts @@ -1,6 +1,5 @@ import { create } from 'zustand'; import { - addEntry, createOfflineEntry, enforceQueueCap, isOfflineEntry, 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';