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:
+11
-4
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user