import { useState, useCallback } from 'react';
import {
createMultisigWallet,
getMultisigBalance,
getMultisigUtxos,
createMultisigPsbt,
signMultisigPsbt,
combinePsbt,
finalizeMultisigPsbt,
broadcastTx,
type MultisigParticipant,
type MultisigWallet,
} from './api';
function satToBtc(sats: number): string {
return (sats / 100_000_000).toFixed(8);
}
function shortenAddr(addr: string): string {
if (addr.length < 20) return addr;
return addr.slice(0, 10) + '…' + addr.slice(-6);
}
function shortenHex(hex: string, start = 10, end = 8): string {
if (hex.length < start + end) return hex;
return hex.slice(0, start) + '…' + hex.slice(-end);
}
type MultisigFlow = 'create' | 'created' | 'psbt_create' | 'psbt_unsigned' | 'signing' | 'psbt_signed' | 'broadcasting' | 'broadcast_done' | 'error';
export default function MultisigCreation({ onBack }: { onBack: () => void }) {
const [m, setM] = useState(2);
const [n, setN] = useState(3);
const [walletName, setWalletName] = useState('Multisig 1');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [participants, setParticipants] = useState<MultisigParticipant[]>([]);
const [wallet, setWallet] = useState<MultisigWallet | null>(null);
const [flow, setFlow] = useState<MultisigFlow>('create');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [toast, setToast] = useState('');
// PSBT state
const [psbtHex, setPsbtHex] = useState('');
const [rawTxHex, setRawTxHex] = useState('');
const [broadcastTxid, setBroadcastTxid] = useState('');
const [externalPsbt, setExternalPsbt] = useState('');
const [walletPath, setWalletPath] = useState('');
const [balance, setBalance] = useState<{ balance_btc: number; balance_sats: number } | null>(null);
const [utxos, setUtxos] = useState<any[]>([]);
const [signaturesCollected, setSignaturesCollected] = useState(0);
const [txOutputAddr, setTxOutputAddr] = useState('');
const [txOutputAmt, setTxOutputAmt] = useState('');
const [txFeeRate, setTxFeeRate] = useState(10);
const showToast = (msg: string) => {
setToast(msg);
setTimeout(() => setToast(''), 2000);
};
const addParticipant = () => {
if (participants.length >= n) {
setError(`Already have ${n} participants (matching n)`);
return;
}
setParticipants([...participants, { name: '', local: false }]);
};
const updateParticipant = (idx: number, field: string, value: string | boolean) => {
const updated = [...participants];
updated[idx] = { ...updated[idx], [field]: value };
setParticipants(updated);
};
const removeParticipant = (idx: number) => {
setParticipants(participants.filter((_, i) => i !== idx));
};
const handleCreate = async () => {
if (m < 1 || m > n) {
setError('m must be between 1 and n');
return;
}
if (participants.length !== n) {
setError(`Need exactly ${n} participants to match n`);
return;
}
if (password && password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setLoading(true);
setError('');
try {
const result = await createMultisigWallet(m, n, participants, walletName, password);
setWallet(result.wallet);
setBalance(result.balance);
if (result.wallet.file_path) setWalletPath(result.wallet.file_path);
setFlow('created');
} catch (e: any) {
setError(e.message || 'Failed to create multisig wallet');
} finally {
setLoading(false);
}
};
const handleRefresh = async () => {
if (!wallet) return;
setLoading(true);
try {
const bal = await getMultisigBalance(wallet);
setBalance(bal);
const utxosResult = await getMultisigUtxos(wallet);
setUtxos(utxosResult.utxos);
} catch (e: any) {
setError(e.message || 'Failed to refresh');
} finally {
setLoading(false);
}
};
const handleCreatePsbt = async () => {
if (!wallet) return;
if (!txOutputAddr || !txOutputAmt) {
setError('Fill in recipient and amount');
return;
}
setLoading(true);
setError('');
try {
const currentUtxos = utxos.length > 0 ? utxos : null;
if (!currentUtxos) {
const utxosResult = await getMultisigUtxos(wallet);
setUtxos(utxosResult.utxos);
const result = await createMultisigPsbt(wallet, [{ address: txOutputAddr, amount: parseFloat(txOutputAmt) }], txFeeRate, utxosResult.utxos);
if (result.error) { setError(result.error); setFlow('error'); }
else { setPsbtHex(result.psbt); setFlow('psbt_unsigned'); }
} else {
const result = await createMultisigPsbt(wallet, [{ address: txOutputAddr, amount: parseFloat(txOutputAmt) }], txFeeRate, currentUtxos);
if (result.error) { setError(result.error); setFlow('error'); }
else { setPsbtHex(result.psbt); setFlow('psbt_unsigned'); }
}
} catch (e: any) {
setError(e.message || 'Failed to create PSBT');
setFlow('error');
} finally {
setLoading(false);
}
};
const handleSign = async () => {
if (!wallet) return;
setLoading(true);
setError('');
try {
const result = await signMultisigPsbt(psbtHex, wallet, password);
if (result.error) { setError(result.error); setFlow('error'); }
else { setPsbtHex(result.psbt); setSignaturesCollected(result.signatures_added); setFlow('psbt_signed'); }
} catch (e: any) {
setError(e.message || 'Failed to sign PSBT');
setFlow('error');
} finally {
setLoading(false);
}
};
const handleCombine = async () => {
setLoading(true);
setError('');
try {
const lines = externalPsbt.split('\n').map(s => s.trim()).filter(s => s.length > 20);
const myPsbt = psbtHex.trim();
const allPsbt = [myPsbt, ...lines];
const result = await combinePsbt(allPsbt);
setPsbtHex(result.psbt);
setSignaturesCollected(prev => prev + lines.length);
setExternalPsbt('');
showToast(`Combined ${lines.length} external PSBT(s)`);
} catch (e: any) {
setError(e.message || 'Failed to combine PSBTs');
setFlow('error');
} finally {
setLoading(false);
}
};
const handleFinalize = async () => {
setLoading(true);
setError('');
try {
const result = await finalizeMultisigPsbt(psbtHex);
if (result.error) { setError(result.error); setFlow('error'); }
else if (result.raw_tx_hex) { setRawTxHex(result.raw_tx_hex); setFlow('broadcasting'); }
else { setError('Finalize did not produce a raw transaction'); setFlow('error'); }
} catch (e: any) {
setError(e.message || 'Failed to finalize PSBT');
setFlow('error');
} finally {
setLoading(false);
}
};
const handleBroadcast = async () => {
setLoading(true);
setError('');
try {
const result = await broadcastTx(rawTxHex);
if (result.error) { setError(result.error); setFlow('error'); }
else { setBroadcastTxid(result.txid); setFlow('broadcast_done'); }
} catch (e: any) {
setError(e.message || 'Broadcast failed');
setFlow('error');
} finally {
setLoading(false);
}
};
const handleReset = () => {
setFlow('created');
setPsbtHex('');
setRawTxHex('');
setBroadcastTxid('');
setError('');
setSignaturesCollected(0);
setExternalPsbt('');
setTxOutputAddr('');
setTxOutputAmt('');
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text).then(() => showToast(`${label} copied!`));
};
const localCount = participants.filter(p => p.local).length;
const remoteCount = participants.length - localCount;
// ================= CREATION FORM =================
if (flow === 'create') {
return (
<div className="multisig-container">
{toast && <div className="toast-notification">{toast}</div>}
<button className="btn-secondary" onClick={onBack}>← Back</button>
<h2>Create Multisig Wallet</h2>
<p className="subtitle">Set up an m-of-n P2WSH multisig wallet with descriptor-based address generation</p>
{error && <div className="error-box">{error}</div>}
<div className="multisig-form">
<div className="form-row">
<div className="form-group">
<label>Threshold (m)</label>
<input type="number" min={1} max={n} value={m} onChange={e => setM(parseInt(e.target.value) || 1)} className="input" />
</div>
<div className="form-group">
<label>Participants (n)</label>
<input type="number" min={2} max={8} value={n} onChange={e => setN(parseInt(e.target.value) || 2)} className="input" />
</div>
</div>
<div className="multisig-quorum">
<span className="badge badge-ok">{m}-of-{n}</span>
<span className="mono">{m} signature{m > 1 ? 's' : ''} required to spend</span>
</div>
<label>Wallet Name</label>
<input type="text" value={walletName} onChange={e => setWalletName(e.target.value)} className="input" />
<label>Password (optional)</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Encrypt wallet" className="input" />
{password && (
<>
<label>Confirm password</label>
<input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} className="input" />
</>
)}
<div className="section-header">
<h4>Participants ({participants.length}/{n})</h4>
<button className="btn-small" onClick={addParticipant} disabled={participants.length >= n}>+ Add</button>
</div>
{participants.map((p, idx) => (
<div key={idx} className="participant-row">
<span className="mono">#{idx + 1}</span>
<input type="text" value={p.name} onChange={e => updateParticipant(idx, 'name', e.target.value)} placeholder="Name" className="input" />
<label className="checkbox-label">
<input type="checkbox" checked={p.local} onChange={e => updateParticipant(idx, 'local', e.target.checked)} />
Local (I have the seed)
</label>
{participants.length > 1 && (
<button className="btn-icon" onClick={() => removeParticipant(idx)} title="Remove">✕</button>
)}
</div>
))}
{participants.length > 0 && (
<div className="participant-summary">
{localCount > 0 && <span className="badge badge-ok">🔑 {localCount} local</span>}
{remoteCount > 0 && <span className="badge badge-warn">🌐 {remoteCount} external</span>}
</div>
)}
{participants.length < n && (
<div className="warn-box">Add {n - participants.length} more participant{ n - participants.length > 1 ? 's' : ''} to match n={n}</div>
)}
<button className="btn-primary" onClick={handleCreate} disabled={loading || participants.length !== n}>
{loading ? 'Creating…' : `Create ${m}-of-${n} Multisig`}
</button>
</div>
</div>
);
}
// ================= CREATED VIEW =================
if (flow === 'created' && wallet) {
return (
<div className="multisig-container">
{toast && <div className="toast-notification">{toast}</div>}
<button className="btn-secondary" onClick={onBack}>← Back</button>
<div className="created-header">
<h2>✓ {wallet.name}</h2>
<button className="btn-small" onClick={handleRefresh} disabled={loading}>{loading ? '⟳' : '↻ Refresh'}</button>
</div>
<div className="multisig-created">
<div className="meta-row">
<span className="meta-label">Threshold</span>
<span className="multisig-quorum">
<span className="badge badge-ok">{wallet.m}-of-{wallet.n}</span>
</span>
</div>
<div className="meta-row">
<span className="meta-label">Address</span>
<div className="addr-copy">
<span className="mono" title={wallet.address}>{shortenAddr(wallet.address)}</span>
<button className="btn-icon" onClick={() => copyToClipboard(wallet.address, 'Address')} title="Copy">📋</button>
</div>
</div>
<div className="meta-row">
<span className="meta-label">Descriptor</span>
<span className="mono" title={wallet.descriptor}>{shortenHex(wallet.descriptor, 16, 12)}</span>
</div>
{walletPath && (
<div className="meta-row">
<span className="meta-label">Saved</span>
<span className="mono">{walletPath}</span>
</div>
)}
<div className="balance-display">
{balance ? (
<span className="balance-value">{satToBtc(balance.balance_sats)} BTC</span>
) : (
<span className="stat-placeholder">Loading balance…</span>
)}
</div>
{utxos.length > 0 && (
<div className="section">
<h4>UTXOs ({utxos.length})</h4>
<table className="utxo-table">
<thead><tr><th>TxID</th><th>VOut</th><th>Amount</th><th>Confs</th></tr></thead>
<tbody>
{utxos.map((u, i) => (
<tr key={i}>
<td className="mono" title={u.txid}>{shortenHex(u.txid, 8, 6)}</td>
<td className="mono">{u.vout}</td>
<td className="mono">{(u.amount ?? 0).toFixed(8)} BTC</td>
<td className="mono">{u.confirmations ?? 0}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Participants */}
<div className="section">
<h4>Participants</h4>
<div className="participant-list">
{wallet.participants.map((p: any) => (
<div key={p.index} className="participant-card">
<span className="mono">#{p.index + 1}</span>
<span>{p.name}</span>
<span className={`badge ${p.local ? 'badge-ok' : 'badge-warn'}`}>
{p.local ? '🔑 Local' : '🌐 External'}
</span>
</div>
))}
</div>
</div>
{/* Export wallet JSON */}
<div className="psbt-output">
<h4>Share with participants</h4>
<div className="psbt-hex" onClick={() => copyToClipboard(JSON.stringify(wallet, null, 2), 'Wallet JSON')}>
{JSON.stringify({ name: wallet.name, m: wallet.m, n: wallet.n, address: wallet.address, descriptor: wallet.descriptor, participants: wallet.participants.map(p => ({ index: p.index, name: p.name, xpub: p.xpub })) }, null, 2)}
</div>
<div className="psbt-actions">
<button className="btn-small" onClick={() => copyToClipboard(JSON.stringify(wallet, null, 2), 'Wallet JSON')}>📋 Copy JSON</button>
<button className="btn-small" onClick={() => {
const blob = new Blob([JSON.stringify(wallet, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${wallet.name.replace(/\s+/g, '_')}_multisig.json`;
a.click();
URL.revokeObjectURL(url);
showToast('Wallet JSON downloaded');
}}>💾 Download</button>
</div>
</div>
</div>
{/* Signature workflow */}
<div className="section">
<h3>Signature Workflow</h3>
{flow === 'created' && (
<div className="send-form">
<h4>Create Transaction</h4>
<label>Recipient Address</label>
<input className="input" type="text" value={txOutputAddr} onChange={e => setTxOutputAddr(e.target.value)} placeholder="bc1q..." />
<label>Amount (BTC)</label>
<input className="input" type="number" step="0.00000001" min="0" value={txOutputAmt} onChange={e => setTxOutputAmt(e.target.value)} placeholder="0.00000000" />
<label>Fee Rate: {txFeeRate} sats/vB</label>
<input type="range" min="1" max="10000" step="1" value={txFeeRate} onChange={e => setTxFeeRate(parseInt(e.target.value))} className="fee-slider" />
<button className="btn-primary" onClick={handleCreatePsbt} disabled={loading || !txOutputAddr || !txOutputAmt}>
{loading ? 'Creating…' : 'Create PSBT'}
</button>
</div>
)}
{flow === 'psbt_unsigned' && (
<div className="psbt-output">
<h4>Unsigned PSBT — Sign locally, then share</h4>
<div className="psbt-hex" onClick={() => copyToClipboard(psbtHex, 'PSBT hex')}>{shortenHex(psbtHex)}</div>
<div className="psbt-actions">
<button className="btn-small" onClick={() => copyToClipboard(psbtHex, 'PSBT hex')}>📋 Copy</button>
<button className="btn-small" onClick={() => {
const blob = new Blob([psbtHex], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${wallet.name}_unsigned_psbt.txt`;
a.click();
URL.revokeObjectURL(url);
showToast('PSBT exported');
}}>💾 Export</button>
</div>
<button className="btn-primary" onClick={handleSign} disabled={loading}>
{loading ? 'Signing…' : '✍️ Sign My Inputs'}
</button>
</div>
)}
{(flow === 'signing' || flow === 'psbt_signed') && (
<div className="psbt-output">
{flow === 'signing' && <p className="mono">Signing…</p>}
{flow === 'psbt_signed' && (
<>
<h4>✓ Signed ({signaturesCollected} local signature{signaturesCollected > 1 ? 's' : ''})</h4>
<div className="psbt-hex" onClick={() => copyToClipboard(psbtHex, 'Signed PSBT')}>{shortenHex(psbtHex)}</div>
<div className="psbt-actions">
<button className="btn-small" onClick={() => copyToClipboard(psbtHex, 'Signed PSBT')}>📋 Copy Signed PSBT</button>
</div>
{signaturesCollected < wallet.m && (
<>
<textarea className="input" value={externalPsbt} onChange={e => setExternalPsbt(e.target.value)} placeholder="Paste PSBT from other signer (one per line)" rows={4} />
<button className="btn-secondary" onClick={handleCombine} disabled={loading || !externalPsbt.trim()}>
{loading ? 'Combining…' : '🔗 Combine PSBTs'}
</button>
</>
)}
</>
)}
</div>
)}
{/* Progress bar */}
{flow !== 'created' && flow !== 'create' && (
<div className="sig-progress">
<div className="sig-progress-bar">
<div className="sig-progress-fill" style={{ width: `${Math.min((signaturesCollected / wallet.m) * 100, 100)}%` }}></div>
</div>
<span className="mono">{signaturesCollected}/{wallet.m} signatures</span>
{signaturesCollected >= wallet.m && (
<button className="btn-primary" onClick={handleFinalize} disabled={loading}>
{loading ? 'Finalizing…' : '⚡ Finalize & Prepare for Broadcast'}
</button>
)}
</div>
)}
{flow === 'broadcasting' && (
<div className="send-preview">
<h4>Ready to Broadcast</h4>
<div className="preview-row"><span>Raw TX</span><span className="mono">{shortenHex(rawTxHex)}</span></div>
<button className="btn-primary btn-danger" onClick={handleBroadcast} disabled={loading}>
{loading ? 'Broadcasting…' : '🚀 Broadcast'}
</button>
</div>
)}
{flow === 'broadcast_done' && (
<div className="send-done">
<div className="success-icon">✓</div>
<h3>Broadcasted!</h3>
<div className="tx-status">
<div className="status-row"><span className="status-label">TXID</span><span className="mono" title={broadcastTxid}>{shortenHex(broadcastTxid, 10, 8)}</span></div>
</div>
<button className="btn-primary" onClick={handleReset}>Create Another</button>
</div>
)}
{flow === 'error' && (
<div className="send-error">
<div className="error-icon">✕</div>
<h3>Error</h3>
<p className="mono">{error}</p>
<button className="btn-primary" onClick={() => { setError(''); setFlow('created'); }}>Back</button>
</div>
)}
</div>
</div>
);
}
return null;
}