Files
eranos/src/components/BitcoinRecipientInput.tsx
T
2026-05-30 20:13:03 +02:00

611 lines
24 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ClipboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Bitcoin, EyeOff, QrCode, X } from 'lucide-react';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverAnchor,
PopoverContent,
} from '@/components/ui/popover';
import { QrScannerDialog } from '@/components/QrScannerDialog';
import { useToast } from '@/hooks/useToast';
import { parseBitcoinUri, validateBitcoinAddress } from '@/lib/bitcoin';
import {
isSilentPaymentAddress,
validateSilentPaymentAddress,
} from '@/lib/hdwallet/sp/sender';
import { cn } from '@/lib/utils';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* The resolved recipient produced by {@link BitcoinRecipientInput}.
*
* Either a bare on-chain Bitcoin address (`kind === 'address'`) or a BIP-352
* silent payment address (`kind === 'sp'`). The dialog consumes this shape
* directly when building the PSBT.
*/
export interface ResolvedRecipient {
/**
* For `kind === 'address'`: a validated mainnet on-chain address.
* For `kind === 'sp'`: the `sp1…` string (the real P2TR `P_k` is derived
* at PSBT-build time, after coin selection).
*/
address: string;
/** Recipient kind — determines how the PSBT builder routes the output. */
kind: 'address' | 'sp';
/**
* Raw text the user typed / pasted / scanned. Kept so the picker can
* round-trip a chip back into the input on clear if we ever need it
* (currently unused; the chip just dismisses).
*/
raw: string;
}
// ---------------------------------------------------------------------------
// Candidate extraction
// ---------------------------------------------------------------------------
/**
* Resolve a piece of recipient text into the valid on-chain and/or
* silent-payment candidates it carries.
*
* Handles bare `bc1…` / `sp1…` addresses and `bitcoin:` BIP-21 URIs (which
* may carry an on-chain path, an `sp=` parameter, or both). Returns empty
* strings for whichever kind isn't present/valid. Shared by the live
* input memo and the paste handler so both agree on what counts.
*/
function resolveCandidates(text: string): { btc: string; sp: string } {
const trimmed = text.trim();
if (!trimmed) return { btc: '', sp: '' };
const bip21 = parseBitcoinUri(trimmed);
// On-chain: the URI path (when present) or the raw input. SP addresses
// live in the `sp` field; don't double-count them as on-chain.
const btcRaw = bip21 ? bip21.address : trimmed;
const btc =
btcRaw && !isSilentPaymentAddress(btcRaw) && validateBitcoinAddress(btcRaw)
? btcRaw
: '';
// Silent payment: prefer the URI `sp=` parameter; otherwise the path may
// itself be an sp1 address (rare but legal — `bitcoin:sp1…` is a URI
// without an on-chain fallback), or the raw input is a bare sp1.
const spRaw = bip21 ? (bip21.sp ?? bip21.address) : trimmed;
const sp =
spRaw && isSilentPaymentAddress(spRaw) && validateSilentPaymentAddress(spRaw)
? spRaw
: '';
return { btc, sp };
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface BitcoinRecipientInputProps {
/** Currently-selected recipient, or `null` when nothing has been picked. */
value: ResolvedRecipient | null;
/** Called when the user picks a recipient (from the dropdown / QR scan) or clears. */
onChange: (value: ResolvedRecipient | null) => void;
/** Input placeholder text. */
placeholder: string;
/**
* Optional initial input value applied when the picker mounts with no
* `value`. Used by callers (e.g. campaign donate flow) that want to
* pre-fill a `bitcoin:…` URI or bare address so the donor only needs to
* pick from the dropdown.
*
* Applied on mount only. Clearing a selected chip (value → null) returns
* to an empty input rather than restoring the prefill.
*/
initialInput?: string;
}
/**
* Recipient input for the Send Bitcoin dialog. Combines a text input, an
* inline QR-scanner button, and a Radix Popover dropdown that surfaces the
* recognised destination(s) extracted from the input.
*
* Recognised destinations:
*
* - Bare on-chain Bitcoin address (any standard mainnet type) → "Send to
* Bitcoin address" row.
* - Bare BIP-352 silent payment address (`sp1…`) → "Send to silent payment
* address" row.
* - `bitcoin:` BIP-21 URI with an on-chain path and/or an `sp=` parameter →
* one row per valid candidate (so a URI carrying both shows two rows and
* the donor picks privacy vs. compatibility).
*
* Clicking a row swaps the input out for a {@link SelectedRecipientChip} via
* `onChange`. Clicking the chip's X button calls `onChange(null)`, which
* returns to the input view.
*
* Anything else (npub, nprofile, free text) is silently ignored — there is
* no account search here, by design. The dropdown stays open as long as the
* input holds at least one valid candidate; it doesn't dismiss when the
* input loses focus or the user taps elsewhere. It closes only on selection,
* when the input is cleared, or on Escape.
*/
export function BitcoinRecipientInput({
value,
onChange,
placeholder,
initialInput,
}: BitcoinRecipientInputProps) {
const { t } = useTranslation();
const { toast } = useToast();
// Local input state. Independent of `value` so the user can keep typing
// after dismissing the dropdown without losing their query, and so the
// chip-cleared view starts blank instead of repopulating the previous
// selection. `initialInput` only seeds the field on first mount —
// clearing the chip (value → null) returns to an empty input, not the
// prefill.
const [query, setQuery] = useState<string>(initialInput ?? '');
const [open, setOpen] = useState(false);
// Tracks whether the popover has been opened at least once for the
// current query. The "choose a payment method" hint suppresses on the
// very first render so callers prefilling the input don't see the hint
// flash for one frame before the auto-open effect runs.
const [hasOpenedForQuery, setHasOpenedForQuery] = useState(false);
const [scannerOpen, setScannerOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// ── Candidate extraction ──────────────────────────────────────────────
//
// BIP-21 `bitcoin:` URI handling. If the input is a URI, we route the
// same way the QR scanner does: surface every valid candidate as its own
// row so the user explicitly picks privacy (sp) vs. compatibility
// (on-chain). A raw bc1…/sp1… input falls through here unchanged: `bip21`
// is null and the candidate is just the trimmed query.
const trimmed = query.trim();
const { btc: btcCandidate, sp: spCandidate } = useMemo(
() => resolveCandidates(trimmed),
[trimmed],
);
const hasBtc = !!btcCandidate;
const hasSp = !!spCandidate;
const totalItems = (hasSp ? 1 : 0) + (hasBtc ? 1 : 0);
// Auto-open the dropdown whenever a candidate is available, auto-close on
// empty input.
useEffect(() => {
if (trimmed.length === 0) {
setOpen(false);
setHasOpenedForQuery(false);
return;
}
if (hasSp || hasBtc) setOpen(true);
}, [trimmed, hasSp, hasBtc]);
// Track the first time the popover opens for the current query, so the
// "choose a payment method" hint only appears after the donor has had a
// chance to see (and dismiss) the dropdown — not flash for one paint
// frame between mount and the auto-open effect above.
useEffect(() => {
if (open) setHasOpenedForQuery(true);
}, [open]);
// ── Selection callbacks ───────────────────────────────────────────────
const selectBtc = useCallback(
(address: string) => {
onChange({ address, kind: 'address', raw: query });
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange, query],
);
const selectSp = useCallback(
(address: string) => {
onChange({ address, kind: 'sp', raw: query });
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange, query],
);
// ── Mount-time auto-select for single-endpoint prefills ────────────────
//
// When the picker mounts pre-filled (e.g. the campaign "Pay with Agora"
// flow) and `initialInput` resolves to exactly one valid candidate, skip
// the dropdown and select it directly so it lands as a chip. When the
// prefill carries *both* an on-chain address and an sp1 code we leave it
// in the input and let the dropdown surface both rows — that's a genuine
// choice the donor must make (privacy vs. compatibility).
//
// Guarded by a ref so it fires once per mount and never overrides a
// selection the user has already made or a `clear chip → restore prefill`
// transition (the picker is keyed on each open in the dialog, so a fresh
// mount is the right granularity).
const autoSelectedRef = useRef(false);
useEffect(() => {
if (autoSelectedRef.current) return;
autoSelectedRef.current = true;
if (value || !initialInput) return;
if (totalItems !== 1) return;
if (hasSp) {
selectSp(spCandidate);
} else if (hasBtc) {
selectBtc(btcCandidate);
}
// Intentionally mount-only: candidates are derived from `initialInput`
// (via the initial `query`), so reading them here reflects the prefill.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── Paste auto-select ──────────────────────────────────────────────────
//
// When the user pastes text that resolves to exactly one valid candidate
// (a bare `bc1…` / `sp1…` address or a single-endpoint `bitcoin:` URI),
// convert it straight into a chip instead of making them click the lone
// dropdown row. A paste carrying *both* an on-chain address and an sp1
// code falls through to the normal dropdown so the donor picks privacy
// vs. compatibility.
//
// We resolve from the pasted text directly because `query` state hasn't
// updated yet inside the paste event. Returning early on a single match
// lets us `preventDefault()` so the input never flickers the raw text.
const handlePaste = useCallback(
(e: ClipboardEvent<HTMLInputElement>) => {
const pasted = e.clipboardData.getData('text');
if (!pasted) return;
const { btc, sp } = resolveCandidates(pasted);
const count = (btc ? 1 : 0) + (sp ? 1 : 0);
if (count !== 1) return; // 0 → let it land as text; 2 → use the dropdown.
e.preventDefault();
if (btc) {
onChange({ address: btc, kind: 'address', raw: pasted.trim() });
} else {
onChange({ address: sp, kind: 'sp', raw: pasted.trim() });
}
setQuery('');
setOpen(false);
inputRef.current?.blur();
},
[onChange],
);
// ── QR scan handling ──────────────────────────────────────────────────
/**
* Interpret a freshly-scanned QR code.
*
* - **BIP-21 URI with valid bc1 *and* sp1** → drop the URI into the input
* and open the dropdown so the donor picks between them.
* - **BIP-21 URI with only `sp=` valid** → select SP directly (creates
* the chip, bypasses the dropdown).
* - **Bare bitcoin address** → select on-chain directly.
* - **Bare `sp1…` address** → select SP directly.
* - **Anything else** → toast.
*/
const handleScan = useCallback(
(scanned: string) => {
setScannerOpen(false);
const text = scanned.trim();
const parsed = parseBitcoinUri(text);
const candidate = parsed ? parsed.address : text;
const sp = parsed?.sp;
const hasValidBtc = !!candidate && validateBitcoinAddress(candidate);
const hasValidSp =
!!sp && isSilentPaymentAddress(sp) && validateSilentPaymentAddress(sp);
// Both options — show the dropdown.
if (parsed && hasValidBtc && hasValidSp) {
setQuery(text);
setOpen(true);
// Focus is best-effort; on mobile the scanner dialog dismissal will
// already steal focus and the dropdown stays usable via tap.
inputRef.current?.focus();
return;
}
// SP-only via `bitcoin:…?sp=sp1…`.
if (hasValidSp && sp) {
selectSp(sp);
return;
}
// Direct on-chain.
if (hasValidBtc) {
selectBtc(candidate);
return;
}
// Bare sp1 (no `bitcoin:` prefix).
if (
isSilentPaymentAddress(candidate)
&& validateSilentPaymentAddress(candidate)
) {
selectSp(candidate);
return;
}
toast({
title: t('walletSend.scanError.title'),
description: t('walletSend.scanError.description'),
variant: 'destructive',
});
},
[selectBtc, selectSp, t, toast],
);
// ── Chip view ─────────────────────────────────────────────────────────
if (value) {
return (
<SelectedRecipientChip value={value} onClear={() => onChange(null)} />
);
}
// ── Input + dropdown ──────────────────────────────────────────────────
//
// `popoverOpen` derives from the manual `open` flag AND the presence of
// actionable candidates. This prevents an empty/garbage input from
// popping the dropdown.
const popoverOpen = open && totalItems > 0;
return (
<div className="space-y-2">
<Popover open={popoverOpen} onOpenChange={setOpen}>
<PopoverAnchor asChild>
<div className="relative flex items-center">
<Input
ref={inputRef}
id="hd-recipient-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
onPaste={handlePaste}
// Reopen on focus so a user can recover the dropdown after an
// outside-click dismiss (the value is still in the field).
onFocus={() => {
if (totalItems > 0) setOpen(true);
}}
// `onFocus` only fires on the first tap; subsequent taps while
// the input is still focused need their own opener so the user
// can reopen the choice list without un-focusing first.
onClick={() => {
if (totalItems > 0) setOpen(true);
}}
placeholder={placeholder}
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={popoverOpen}
aria-haspopup="listbox"
aria-autocomplete="list"
className={cn('font-mono text-base md:text-sm pr-11')}
/>
<button
type="button"
onClick={() => setScannerOpen(true)}
aria-label={t('walletSend.recipient.scan')}
className="absolute right-1 top-1/2 -translate-y-1/2 size-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 flex items-center justify-center motion-safe:transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<QrCode className="size-4" />
</button>
</div>
</PopoverAnchor>
<PopoverContent
align="start"
sideOffset={6}
// Keep typing focus in the input on open/close — Radix's default
// is to focus the popover content, which would steal focus from
// the input and dismiss the mobile keyboard mid-type.
onOpenAutoFocus={(e) => e.preventDefault()}
onCloseAutoFocus={(e) => e.preventDefault()}
// The dropdown is a persistent choice list, not a transient
// hover-popover: it should stay open even when the input loses
// focus or the user taps elsewhere on the page, so blurring out
// doesn't make the candidate rows vanish. We block Radix's
// auto-dismiss-on-outside-interaction and instead close the
// dropdown explicitly — on selection, on a cleared input
// (the auto-open effect), or via Escape (still honored below).
onFocusOutside={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
style={{ width: 'var(--radix-popover-trigger-width)' }}
className="p-0 w-[--radix-popover-trigger-width] max-h-none rounded-xl border border-border bg-popover shadow-lg overflow-hidden"
>
<div role="listbox" className="max-h-[280px] overflow-y-auto py-1">
{/* BTC comes before SP — the on-chain address is the
broadly-compatible default; the silent-payment option
follows for donors who want privacy. */}
{hasBtc && (
<BtcAddressRow address={btcCandidate} onClick={selectBtc} />
)}
{hasSp && (
<SpAddressRow address={spCandidate} onClick={selectSp} />
)}
</div>
</PopoverContent>
</Popover>
{/* Picker-closed reminder. When the input holds parseable candidates
but the donor hasn't actually picked one yet — typically because
they tapped an amount preset, which counts as an outside-click
and dismisses the popover — the Send button is disabled with no
visible reason. Surface an actionable hint that re-opens the
dropdown so the donor doesn't have to guess that they're meant
to tap the recipient input again.
Gated on `hasOpenedForQuery` so the hint doesn't flash for one
paint frame between mount and the auto-open effect on prefilled
inputs (campaign donate flow). */}
{hasOpenedForQuery && !popoverOpen && totalItems > 0 && (
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setOpen(true);
inputRef.current?.focus();
}}
className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500 hover:text-amber-700 dark:hover:text-amber-400 motion-safe:transition-colors text-left"
>
<AlertTriangle className="size-3.5 shrink-0" />
<span>{t('walletSend.recipient.choosePaymentMethod')}</span>
</button>
)}
<QrScannerDialog
isOpen={scannerOpen}
onClose={() => setScannerOpen(false)}
onScan={handleScan}
title={t('walletSend.recipient.scan')}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Dropdown rows
// ---------------------------------------------------------------------------
/** Truncate long addresses with an ellipsis so they don't overflow the row. */
function truncateAddress(address: string): string {
return address.length > 28
? `${address.slice(0, 14)}${address.slice(-10)}`
: address;
}
function BtcAddressRow({
address,
onClick,
}: {
address: string;
onClick: (address: string) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => onClick(address)}
// Prevent the input from blurring on mousedown — otherwise the popover
// closes before `onClick` fires and the row never resolves.
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToOnchain')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
/**
* Dropdown row for BIP-352 silent payment addresses. We give it a distinct
* label and icon (privacy eye-off) so the user can tell at a glance that
* this is a static, unlinkable address rather than a regular Bitcoin
* scriptPubKey — the privacy story is materially different.
*/
function SpAddressRow({
address,
onClick,
}: {
address: string;
onClick: (address: string) => void;
}) {
const { t } = useTranslation();
return (
<button
type="button"
role="option"
aria-selected={false}
onClick={() => onClick(address)}
onMouseDown={(e) => e.preventDefault()}
className="w-full flex items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer hover:bg-secondary/60"
>
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-sm truncate">
{t('walletSend.recipient.sendToSilentPayment')}
</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{truncateAddress(address)}
</div>
</div>
</button>
);
}
// ---------------------------------------------------------------------------
// Selected recipient chip
// ---------------------------------------------------------------------------
/**
* Compact panel that replaces the input once a recipient has been picked.
* Renders a coloured icon (orange Bitcoin / violet EyeOff for SP), the kind
* label, a truncated monospace address, and an X button that clears the
* selection and returns the user to the input view.
*/
function SelectedRecipientChip({
value,
onClear,
}: {
value: ResolvedRecipient;
onClear: () => void;
}) {
const { t } = useTranslation();
const { address, kind } = value;
const displayName =
kind === 'sp'
? t('walletSend.recipient.silentPayment')
: t('walletSend.recipient.bitcoinAddress');
const subtitle = truncateAddress(address);
return (
<div className="flex items-center gap-3 rounded-2xl border border-border bg-muted/40 pl-2 pr-2 py-1.5 w-full min-w-0 max-w-full">
{kind === 'sp' ? (
<div className="size-9 shrink-0 rounded-full bg-violet-500/10 flex items-center justify-center">
<EyeOff className="size-4 text-violet-500" />
</div>
) : (
<div className="size-9 shrink-0 rounded-full bg-orange-500/10 flex items-center justify-center">
<Bitcoin className="size-4 text-orange-500" />
</div>
)}
<div className="flex-1 min-w-0 overflow-hidden">
<div className="text-[11px] text-muted-foreground leading-tight">
{t('walletSend.recipient.toLabel')}
</div>
<div className="text-sm font-medium truncate">{displayName}</div>
<div className="text-xs text-muted-foreground truncate font-mono">
{subtitle}
</div>
</div>
<button
type="button"
onClick={onClear}
aria-label={t('walletSend.recipient.clear')}
className="p-1.5 rounded-full text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors shrink-0"
>
<X className="size-4" />
</button>
</div>
);
}