diff --git a/src/locales/en.json b/src/locales/en.json index 65ad09e0..d0a2ceb5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1654,17 +1654,24 @@ }, "scan": { "title": "Scan for stranded payments", - "description": "Choose the block height to start scanning from. Recovery checks every block from there to the chain tip.", - "fromHeightLabel": "Start block height", - "tipHint": "Current chain tip: {{tip}}", + "description": "Choose how far back to scan. Recovery checks the blockchain from the selected time window to the present.", + "since": "Since", + "advanced": "Advanced", + "fromBlock": "From block", + "connectingIndexer": "Connecting to indexer…", + "tipHint": "Indexer tip: {{tip}}", "start": "Scan", "cancel": "Cancel scan", "progress": "Scanning block {{current}} of {{to}} — {{found}} found", "tipMissing": "Resolving chain tip…" }, + "resolveFailed": { + "title": "Couldn't look up the start block", + "description": "mempool.space is unreachable right now. Enter a starting block under Advanced → From block to scan anyway." + }, "noFunds": { "title": "Nothing to recover", - "description": "No stranded silent payments were found in the scanned range. Try an earlier start height if you expected funds." + "description": "No stranded silent payments were found in the scanned range. Try a longer time window if you expected funds." }, "found": { "title": "Stranded payments found", diff --git a/src/pages/WalletDoubleTweakFixPage.tsx b/src/pages/WalletDoubleTweakFixPage.tsx index c9983cd1..ec954076 100644 --- a/src/pages/WalletDoubleTweakFixPage.tsx +++ b/src/pages/WalletDoubleTweakFixPage.tsx @@ -1,10 +1,12 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, CheckCircle2, + ChevronDown, + ChevronUp, Loader2, Search, Wallet as WalletIcon, @@ -13,10 +15,18 @@ import { import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { LoginArea } from '@/components/auth/LoginArea'; import { PageHeader } from '@/components/PageHeader'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/useToast'; import { useHdWallet } from '@/hooks/useHdWallet'; @@ -35,6 +45,62 @@ type Step = 'idle' | 'sweeping' | 'success' | 'error'; /** sat/vB — conservative default for the recovery sweep. */ const SWEEP_FEE_RATE = 5; +// --------------------------------------------------------------------------- +// "Since" presets — same pattern as HDSilentPaymentScanDialog +// --------------------------------------------------------------------------- + +const PRESETS = { + lastHour: { seconds: 60 * 60 }, + last3h: { seconds: 3 * 60 * 60 }, + last24h: { seconds: 24 * 60 * 60 }, + lastWeek: { seconds: 7 * 24 * 60 * 60 }, + lastMonth: { seconds: 30 * 24 * 60 * 60 }, +} as const; + +type PresetId = keyof typeof PRESETS; + +const CUSTOM_SINCE = 'custom' as const; +type SinceId = PresetId | typeof CUSTOM_SINCE; + +const PRESET_ORDER: PresetId[] = ['lastHour', 'last3h', 'last24h', 'lastWeek', 'lastMonth']; +const SINCE_ORDER: SinceId[] = [...PRESET_ORDER, CUSTOM_SINCE]; +const DEFAULT_SINCE: SinceId = 'lastMonth'; + +/** + * BIP-113 median-time-past safety margin — same 11-block rewind used + * by the regular SP scan dialog to account for out-of-order timestamps. + */ +const TIME_RESOLUTION_SAFETY_BLOCKS = 11; +const MEMPOOL_TIMESTAMP_BLOCK_URL = 'https://mempool.space/api/v1/mining/blocks/timestamp'; + +interface MempoolTimestampBlockResponse { + height?: unknown; +} + +async function fetchMempoolTimestampBlockHeight(cutoffTime: number): Promise { + const response = await fetch(`${MEMPOOL_TIMESTAMP_BLOCK_URL}/${cutoffTime}`); + if (!response.ok) { + throw new Error(`mempool.space timestamp lookup returned ${response.status}`); + } + const data = (await response.json()) as MempoolTimestampBlockResponse; + if (typeof data.height !== 'number' || !Number.isInteger(data.height) || data.height < 0) { + throw new Error('mempool.space timestamp lookup missing valid block height'); + } + return data.height; +} + +async function resolveWindowFromHeight( + windowSeconds: number, + tipHeight: number, +): Promise { + const cutoffTime = Math.floor(Date.now() / 1000) - windowSeconds; + let boundary = await fetchMempoolTimestampBlockHeight(cutoffTime); + boundary = Math.min(boundary, tipHeight); + return Math.max(0, boundary - TIME_RESOLUTION_SAFETY_BLOCKS); +} + +// --------------------------------------------------------------------------- + /** * Recovery page at `/wallet/double-tweak-fix`. * @@ -58,7 +124,11 @@ export function WalletDoubleTweakFixPage() { const blockbookUrl = (config.blockbookBaseUrl ?? '').trim(); const destinationAddress = wallet.currentReceiveAddress?.address; - const [fromHeight, setFromHeight] = useState(String(recovery.defaultFromHeight)); + const [since, setSince] = useState(DEFAULT_SINCE); + const [customHours, setCustomHours] = useState(''); + const [fromOverride, setFromOverride] = useState(''); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [isResolvingSince, setIsResolvingSince] = useState(false); const [step, setStep] = useState('idle'); const [error, setError] = useState(null); const [txid, setTxid] = useState(null); @@ -69,20 +139,75 @@ export function WalletDoubleTweakFixPage() { description: t('walletDoubleTweak.seoDescription'), }); - const fromHeightNum = useMemo(() => { - const n = parseInt(fromHeight, 10); - return Number.isInteger(n) && n >= 0 ? n : undefined; - }, [fromHeight]); + // Parse Advanced → From block override. + const overrideTrimmed = fromOverride.trim(); + const overrideParsed = overrideTrimmed === '' ? undefined : Number(overrideTrimmed); + const overrideValid = + overrideTrimmed === '' || + (Number.isInteger(overrideParsed) && (overrideParsed as number) >= 0); + const effectiveFrom = overrideTrimmed !== '' ? overrideParsed : undefined; + + // Parse Custom hours input. + const customTrimmed = customHours.trim(); + const customParsed = customTrimmed === '' ? undefined : Number(customTrimmed); + const customValid = + customTrimmed === '' || + (typeof customParsed === 'number' && + Number.isFinite(customParsed) && + (customParsed as number) > 0); + const customSeconds = + typeof customParsed === 'number' && customValid && customParsed > 0 + ? Math.round(customParsed * 60 * 60) + : undefined; + + const tipHeight = recovery.tipHeight; + + const sinceReady = since === CUSTOM_SINCE ? customSeconds !== undefined : true; + const canStart = + overrideValid && + customValid && + (overrideTrimmed !== '' ? effectiveFrom !== undefined : tipHeight !== undefined) && + sinceReady && + !recovery.isScanning && + !isResolvingSince; async function runScan() { - if (fromHeightNum === undefined) return; + if (!canStart) return; setStep('idle'); setError(null); setTxid(null); + + // If the user filled in a manual block height override, use it directly. + if (overrideTrimmed !== '') { + if (effectiveFrom === undefined) return; + try { + await recovery.scan({ fromHeight: effectiveFrom }); + } catch (err) { + logger.error('[DoubleTweakFix] scan failed', err); + } + return; + } + + if (tipHeight === undefined) return; + + // Resolve the Since preset / custom hours to a window in seconds. + const windowSeconds = + since === CUSTOM_SINCE ? customSeconds : PRESETS[since].seconds; + if (windowSeconds === undefined) return; + + setIsResolvingSince(true); try { - await recovery.scan({ fromHeight: fromHeightNum }); - } catch (err) { - logger.error('[DoubleTweakFix] scan failed', err); + const fromHeight = await resolveWindowFromHeight(windowSeconds, tipHeight); + await recovery.scan({ fromHeight }); + } catch { + toast({ + title: t('walletDoubleTweak.resolveFailed.title'), + description: t('walletDoubleTweak.resolveFailed.description'), + variant: 'destructive', + }); + setAdvancedOpen(true); + } finally { + setIsResolvingSince(false); } } @@ -202,29 +327,96 @@ export function WalletDoubleTweakFixPage() { {t('walletDoubleTweak.scan.description')} + {/* Primary control: relative time window. */}
-
+ {/* Advanced disclosure — From block override for power users. */} + + + + + + +
+ + setFromOverride(e.target.value)} + disabled={recovery.isScanning} + aria-invalid={!overrideValid} + /> +
+ {tipHeight !== undefined && ( +

+ {t('walletDoubleTweak.scan.tipHint', { tip: tipHeight.toLocaleString() })} +

+ )} +
+
+ + {/* Disabled-state hints. */} + {!recovery.isScanning && tipHeight === undefined && overrideTrimmed === '' && ( +

+ {t('walletDoubleTweak.scan.connectingIndexer')} +

+ )} + {recovery.isScanning ? (