dgb-wallet/packages/mobile/src/SendScreen.tsx
Digibyte.X d50286a9a5 DigiByte.X Wallet — recovery-first web wallet monorepo
Packages: core (BIP39/32/44/49/84/86 derivation, WIF), keystore (PBKDF2 +
AES-GCM), psbt, p2p (BIP157/158 SPV daemon, Dandelion++ broadcast with
relay-echo verification), blockbook, rpc, electrum, net, bns, web-ui
(React/Vite), mobile (Expo). ops/ holds the nginx vhost, systemd unit and
push.sh deploy script for digibyte.x/web.
2026-09-20 20:16:52 +02:00

225 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { View, Text, TextInput, Pressable, ScrollView, ActivityIndicator } from 'react-native';
import {
seedFromPhrase,
validateSeedPhrase,
rootFromSeed,
accountNode,
accountXpub as toXpub,
addressNode,
p2wpkhAddress,
digibyte,
} from '@dgb-wallet/core';
import { buildPsbt, finalizeAndExtract, type Utxo } from '@dgb-wallet/psbt';
import { payments } from 'bitcoinjs-lib';
import { ECPairFactory } from 'ecpair';
import * as ecc from '@bitcoinerlab/secp256k1';
import { p2pBalance, p2pBroadcast, type P2PBroadcastResponse } from './rpc-native';
import { styles } from './styles';
const ECPair = ECPairFactory(ecc);
const FIXED_FEE_SATS = 5000;
const GAP = 20;
type Status =
| { kind: 'idle' }
| { kind: 'busy'; msg: string }
| { kind: 'signed'; hex: string; txid: string; fee: number }
| { kind: 'broadcast'; res: P2PBroadcastResponse }
| { kind: 'error'; msg: string };
export function SendScreen(): JSX.Element {
const [phrase, setPhrase] = useState('');
const [dest, setDest] = useState('');
const [amtDgb, setAmtDgb] = useState('');
const [useDandelion, setUseDandelion] = useState(true);
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const okSeed = validateSeedPhrase(phrase.trim());
const okDest = dest.trim().length > 20;
const okAmt = Number(amtDgb) > 0;
const ready = okSeed && okDest && okAmt;
async function onSign() {
if (!ready) return;
setStatus({ kind: 'busy', msg: 'Deriving keys and scanning P2P for UTXOs…' });
try {
const seed = await seedFromPhrase(phrase.trim());
const root = rootFromSeed(seed);
const acct = accountNode(root, 84, 0);
const xpub = toXpub(acct);
// Pre-derive every candidate script → private key so we can look
// up each returned UTXO by its scriptPubKey hex in O(1).
const spkToKey = new Map<string, Buffer>();
const spkToAddr = new Map<string, string>();
for (const ch of [0, 1] as const) {
for (let idx = 0; idx < GAP; idx++) {
const node = addressNode(acct, ch, idx);
const pubkey = Buffer.from(node.publicKey);
const spk = payments.p2wpkh({ pubkey, network: digibyte }).output;
if (!spk || !node.privateKey) continue;
const spkHex = Buffer.from(spk).toString('hex');
spkToKey.set(spkHex, Buffer.from(node.privateKey));
spkToAddr.set(spkHex, p2wpkhAddress(node));
}
}
const scan = await p2pBalance({ xpub, purpose: 84, account: 0, gapLimit: GAP });
if (scan.utxos.length === 0) {
throw new Error('No spendable UTXOs on any BIP84 address for this seed.');
}
const amtSats = Math.round(Number(amtDgb) * 1e8);
const utxosSorted = [...scan.utxos].sort((a, b) => b.value - a.value);
const inputs: Utxo[] = [];
const inputKeys: Buffer[] = [];
let inputSats = 0;
for (const u of utxosSorted) {
const key = spkToKey.get(u.scriptPubKey);
const addr = spkToAddr.get(u.scriptPubKey);
if (!key || !addr) continue;
inputs.push({
txid: u.txid,
vout: u.vout,
value: u.value,
address: addr,
scriptPubKey: u.scriptPubKey,
witness: { scriptHex: u.scriptPubKey, value: u.value },
});
inputKeys.push(key);
inputSats += u.value;
if (inputSats >= amtSats + FIXED_FEE_SATS) break;
}
if (inputSats < amtSats + FIXED_FEE_SATS) {
throw new Error(`Insufficient funds: have ${(inputSats / 1e8).toFixed(8)}, need ${((amtSats + FIXED_FEE_SATS) / 1e8).toFixed(8)}`);
}
setStatus({ kind: 'busy', msg: 'Building & signing PSBT…' });
const changeAmt = inputSats - amtSats - FIXED_FEE_SATS;
const changeAddr = p2wpkhAddress(addressNode(acct, 1, 0));
const psbt = buildPsbt({
inputs,
outputs: [
{ address: dest.trim(), value: amtSats },
...(changeAmt > 546 ? [{ address: changeAddr, value: changeAmt }] : []),
],
});
for (const [i, key] of inputKeys.entries()) {
const kp = ECPair.fromPrivateKey(key, { network: digibyte });
psbt.signInput(i, kp);
}
const { hex, txid } = finalizeAndExtract(psbt);
setStatus({ kind: 'signed', hex, txid, fee: FIXED_FEE_SATS });
} catch (e) {
setStatus({ kind: 'error', msg: (e as Error).message });
}
}
async function onBroadcast() {
if (status.kind !== 'signed') return;
setStatus({ kind: 'busy', msg: `Broadcasting${useDandelion ? ' (Dandelion++)' : ''}…` });
try {
const res = await p2pBroadcast(status.hex, useDandelion);
setStatus({ kind: 'broadcast', res });
} catch (e) {
setStatus({ kind: 'error', msg: (e as Error).message });
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.warningBlock}>
<Text style={styles.warningText}>
⚠️ Demo scope: BIP84 only, single account, flat 5000-sat fee. Never paste a
real-funds seed on an untrusted device.
</Text>
</View>
<Text style={styles.label}>BIP39 seed</Text>
<TextInput
style={styles.textarea}
placeholder="word1 word2 ... word12"
placeholderTextColor="#475569"
value={phrase} onChangeText={setPhrase}
multiline autoCapitalize="none" autoCorrect={false}
/>
<Text style={styles.label}>Recipient (dgb1q… address)</Text>
<TextInput
style={styles.textInput}
placeholder="dgb1q..." placeholderTextColor="#475569"
value={dest} onChangeText={setDest}
autoCapitalize="none" autoCorrect={false}
/>
<Text style={styles.label}>Amount (DGB)</Text>
<TextInput
style={styles.textInput}
placeholder="0.0" placeholderTextColor="#475569"
keyboardType="decimal-pad"
value={amtDgb} onChangeText={setAmtDgb}
/>
<Text style={styles.tipText}>Fee is a flat {(FIXED_FEE_SATS / 1e8).toFixed(8)} DGB for the demo.</Text>
<Pressable
style={[styles.button, (!ready || status.kind === 'busy') && styles.buttonDisabled]}
disabled={!ready || status.kind === 'busy'}
onPress={onSign}
>
{status.kind === 'busy'
? <ActivityIndicator color="white" />
: <Text style={styles.buttonText}>Build & sign transaction</Text>}
</Pressable>
{status.kind === 'error' && <Text style={styles.errText}>{status.msg}</Text>}
{status.kind === 'signed' && (
<View style={styles.results}>
<View style={styles.pathGroup}>
<Text style={styles.pathTitle}>Signed transaction</Text>
<Text style={styles.pathDesc}>txid: {status.txid}</Text>
<Text style={styles.pathDesc}>fee: {(status.fee / 1e8).toFixed(8)} DGB</Text>
<Text style={styles.label}>Broadcast path</Text>
<View style={styles.toggleRow}>
<Pressable
style={[styles.toggleButton, useDandelion ? styles.toggleActive : styles.toggleInactive]}
onPress={() => setUseDandelion(true)}>
<Text style={styles.toggleText}>Dandelion++ (private)</Text>
</Pressable>
<Pressable
style={[styles.toggleButton, !useDandelion ? styles.toggleActive : styles.toggleInactive]}
onPress={() => setUseDandelion(false)}>
<Text style={styles.toggleText}>Direct fan-out</Text>
</Pressable>
</View>
<Text style={styles.tipText}>
{useDandelion
? 'Relays via a random Dandelion-capable peer before fluff. Falls back to direct if none available.'
: 'Announces to every ready peer. Faster but reveals your IP as the origin.'}
</Text>
<Pressable style={styles.button} onPress={onBroadcast}>
<Text style={styles.buttonText}>Broadcast now</Text>
</Pressable>
</View>
</View>
)}
{status.kind === 'broadcast' && (
<View style={styles.tipBlock}>
<Text style={styles.tipStrong}>Broadcast successful</Text>
<Text style={styles.tipText}>via: {status.res.via ?? 'p2p'}</Text>
{status.res.stemPeer && <Text style={styles.tipText}>stem: {status.res.stemPeer}</Text>}
<Text style={styles.tipText} selectable>txid: {status.res.txid}</Text>
{status.res.via !== 'dandelion' && (
<Text style={styles.tipText}>
{status.res.requestedBy}/{status.res.totalPeers} peers requested the tx.
</Text>
)}
</View>
)}
</ScrollView>
);
}