Show dates instead of block heights in double-tweak recovery UI

Replace the raw block-height input and progress display with
human-readable dates so non-technical users can navigate the recovery
scanner intuitively.

- Add estimateDateFromHeight/estimateHeightFromDate utilities anchored
  to block 840 000 (4th halving) with Bitcoin's 10-min average interval.
- Swap the numeric height <Input> for a <input type="date"> that
  converts the selected date to an estimated height internally.
- Render scan progress and chain-tip hint as localized date strings
  (e.g. "May 15, 2026") instead of block numbers.
- Update en.json labels: fromHeightLabel → fromDateLabel, tipHint and
  progress now interpolate date strings, noFunds nudges an earlier date
  instead of an earlier height.
This commit is contained in:
filemon
2026-05-29 00:08:45 +02:00
parent 545e6cf4be
commit a4d8bf50e3
2 changed files with 232 additions and 32 deletions
+11 -4
View File
@@ -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",
+221 -28
View File
@@ -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<number> {
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<number> {
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<SinceId>(DEFAULT_SINCE);
const [customHours, setCustomHours] = useState('');
const [fromOverride, setFromOverride] = useState('');
const [advancedOpen, setAdvancedOpen] = useState(false);
const [isResolvingSince, setIsResolvingSince] = useState(false);
const [step, setStep] = useState<Step>('idle');
const [error, setError] = useState<string | null>(null);
const [txid, setTxid] = useState<string | null>(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() {
<CardDescription>{t('walletDoubleTweak.scan.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Primary control: relative time window. */}
<div className="space-y-1.5">
<Label htmlFor="dt-from-height" className="text-xs">
{t('walletDoubleTweak.scan.fromHeightLabel')}
<Label htmlFor="dt-scan-since" className="text-xs">
{t('walletDoubleTweak.scan.since')}
</Label>
<Input
id="dt-from-height"
inputMode="numeric"
value={fromHeight}
onChange={(e) => setFromHeight(e.target.value.replace(/[^0-9]/g, ''))}
placeholder={
recovery.defaultFromHeight !== undefined
? String(recovery.defaultFromHeight)
: '—'
}
<Select
value={since}
onValueChange={(v) => setSince(v as SinceId)}
disabled={recovery.isScanning}
/>
{recovery.tipHeight !== undefined && (
<p className="text-[11px] text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: recovery.tipHeight.toLocaleString() })}
</p>
>
<SelectTrigger id="dt-scan-since">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SINCE_ORDER.map((id) => (
<SelectItem key={id} value={id}>
{t(`spScan.preset.${id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{since === CUSTOM_SINCE && (
<div className="pt-1.5 space-y-1.5">
<Label htmlFor="dt-scan-custom-hours" className="text-xs">
{t('spScan.customHours')}
</Label>
<Input
id="dt-scan-custom-hours"
type="number"
inputMode="decimal"
min={0}
step="any"
placeholder={t('spScan.customHoursPlaceholder')}
value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
disabled={recovery.isScanning}
aria-invalid={!customValid}
/>
</div>
)}
</div>
{/* Advanced disclosure — From block override for power users. */}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm cursor-pointer"
>
{advancedOpen ? (
<ChevronUp className="size-3" />
) : (
<ChevronDown className="size-3" />
)}
{t('walletDoubleTweak.scan.advanced')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-3">
<div className="space-y-1.5">
<Label htmlFor="dt-from-block" className="text-xs">
{t('walletDoubleTweak.scan.fromBlock')}
</Label>
<Input
id="dt-from-block"
type="number"
inputMode="numeric"
min={0}
value={fromOverride}
onChange={(e) => setFromOverride(e.target.value)}
disabled={recovery.isScanning}
aria-invalid={!overrideValid}
/>
</div>
{tipHeight !== undefined && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.tipHint', { tip: tipHeight.toLocaleString() })}
</p>
)}
</CollapsibleContent>
</Collapsible>
{/* Disabled-state hints. */}
{!recovery.isScanning && tipHeight === undefined && overrideTrimmed === '' && (
<p className="text-xs text-muted-foreground">
{t('walletDoubleTweak.scan.connectingIndexer')}
</p>
)}
{recovery.isScanning ? (
<div className="space-y-2">
<Button variant="outline" className="w-full" onClick={recovery.cancel}>
@@ -245,8 +437,9 @@ export function WalletDoubleTweakFixPage() {
<Button
className="w-full"
onClick={runScan}
disabled={fromHeightNum === undefined}
disabled={!canStart}
>
{isResolvingSince && <Loader2 className="size-4 animate-spin mr-1.5" />}
<Search className="size-4 mr-1.5" />
{t('walletDoubleTweak.scan.start')}
</Button>