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.
225 lines
8.6 KiB
TypeScript
225 lines
8.6 KiB
TypeScript
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>
|
||
);
|
||
}
|