feat(theseus/bchwallet): send — plan, approval overlay, sign, broadcast

Send tab: recipient (cashaddr or legacy, testnet rejected), amount with
BCH/sat toggle and Max, 1-5 sat/B fee slider, live fee/total preview via
planSend. Sending goes plan -> approval-modal (To/Amount/Fee/Total) ->
ECDSA DER + SIGHASH_ALL|FORKID -> blockchain.transaction.broadcast, then
shows the txid with an explorer link. Confirmed coins are spent before
unconfirmed; dust change folds into the fee. Verified end to end against a
fake Fulcrum: broadcast tx re-parsed, sighash recomputed, signature checks.
This commit is contained in:
Local Dev 2026-09-06 02:49:09 +02:00
parent de576935c1
commit 821cc8e808
3 changed files with 133 additions and 1 deletions

View file

@ -93,6 +93,20 @@ async function deriveAndStart() {
catch (e) { setPhase("error", e?.message || String(e)); c.api.log("wallet build failed:", e?.message); } catch (e) { setPhase("error", e?.message || String(e)); c.api.log("wallet build failed:", e?.message); }
} }
const fmtBch = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
function planFrom(p) {
const c = requireReady();
const spec = p || {};
const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.value }];
return c.wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
}
function describePlan(plan) {
const sent = plan.recipients.reduce((a, r) => a + r.value, 0);
return { recipients: plan.recipients, fee: plan.fee, feeRate: plan.feeRate, inputs: plan.inputs.length, change: plan.change, total: sent + plan.fee };
}
function requireReady() { function requireReady() {
if (!ctx || ctx.phase !== "ready" || !ctx.wallet) throw new Error("wallet is not ready (vault locked?)"); if (!ctx || ctx.phase !== "ready" || !ctx.wallet) throw new Error("wallet is not ready (vault locked?)");
return ctx; return ctx;
@ -120,6 +134,28 @@ function registerPanelMessages(api) {
if (ctx && ctx.root) buildWallet(); if (ctx && ctx.root) buildWallet();
return snapshot(); return snapshot();
}); });
// Send preview — no side effects, used for the live fee/total summary.
api.onMessage("planSend", (p, m) => { fromPanel(m); return describePlan(planFrom(p)); });
// Send for real: plan -> approval overlay -> sign -> broadcast.
api.onMessage("send", async (p, m) => {
fromPanel(m);
const c = requireReady();
const plan = planFrom(p);
const d = describePlan(plan);
const pick = await api.approvalModal({
title: "Send Bitcoin Cash?",
origin: "Theseus wallet panel",
rows: [
{ label: "To", value: d.recipients[0].to, mono: true },
{ label: "Amount", value: fmtBch(d.recipients[0].value) + " BCH", strong: true },
{ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` },
{ label: "Total", value: fmtBch(d.total) + " BCH" },
],
actions: [{ id: "send", label: "Send", primary: true }],
});
if (pick !== "send") throw new Error("cancelled");
return c.wallet.signAndBroadcast(plan);
});
// Recovery info: xpub always; the account xprv only after an explicit // Recovery info: xpub always; the account xprv only after an explicit
// confirmation in the approval overlay. Import the xprv into any BIP32 // confirmation in the approval overlay. Import the xprv into any BIP32
// wallet (branch 0 receive / 1 change) to move funds without Theseus. // wallet (branch 0 receive / 1 change) to move funds without Theseus.

View file

@ -120,7 +120,32 @@
</div> </div>
</section> </section>
<section id="tab-send" hidden> <section id="tab-send" hidden>
<div class="empty">Sending arrives in the next step.</div> <div class="field">
<div class="lbl">Recipient</div>
<input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="bitcoincash:q… or legacy 1…">
<div class="hint" id="sendToHint"></div>
</div>
<div class="field">
<div class="lbl">Amount</div>
<div class="amt">
<input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00">
<div class="unit"><button id="unitBch" class="on" type="button">BCH</button><button id="unitSat" type="button">sat</button></div>
<button class="btn sm" id="sendMax" type="button">Max</button>
</div>
</div>
<div class="field">
<div class="lbl">Fee</div>
<div class="fee"><input type="range" id="feeRate" min="1" max="5" step="1" value="1"><span class="v" id="feeLbl">1 sat/B</span></div>
</div>
<div class="card">
<div class="summary">
<div class="k">Amount</div><div class="v" id="sumAmt"></div>
<div class="k">Network fee</div><div class="v" id="sumFee"></div>
<div class="k">Total</div><div class="v" id="sumTotal"></div>
</div>
</div>
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
<div class="msg" id="sendMsg" hidden></div>
</section> </section>
<section id="tab-history" hidden> <section id="tab-history" hidden>
<div id="txlist"></div> <div id="txlist"></div>

View file

@ -111,6 +111,77 @@ function flash(btn, text) {
setTimeout(() => { btn.textContent = old; }, 1200); setTimeout(() => { btn.textContent = old; }, 1200);
} }
// ---- send ------------------------------------------------------------------
let unit = "bch";
let sendMax = false;
let planTimer = null;
let lastPlan = null;
function amountSats() {
const raw = $("sendAmt").value.trim().replace(/,/g, "");
if (!raw) return 0;
if (unit === "sat") return Math.round(Number(raw));
const [w, f = ""] = raw.split(".");
return Number(w || 0) * 1e8 + Math.round(Number("0." + (f || "0")) * 1e8);
}
function setUnit(u) {
if (u === unit) return;
const s = amountSats();
unit = u;
$("unitBch").classList.toggle("on", u === "bch"); $("unitSat").classList.toggle("on", u === "sat");
$("sendAmt").placeholder = u === "bch" ? "0.00" : "0";
if (s) $("sendAmt").value = u === "bch" ? fmtBch(s) : String(s);
}
$("unitBch").addEventListener("click", () => setUnit("bch"));
$("unitSat").addEventListener("click", () => setUnit("sat"));
$("sendMax").addEventListener("click", () => {
sendMax = !sendMax;
$("sendMax").classList.toggle("primary", sendMax);
$("sendAmt").disabled = sendMax;
if (!sendMax) $("sendAmt").value = "";
schedulePlan();
});
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); }));
function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); }
async function updatePlan() {
const to = $("sendTo").value.trim();
const msg = $("sendMsg"); msg.hidden = true;
lastPlan = null; $("sendBtn").disabled = true;
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
$("sendToHint").textContent = "";
if (!to || (!sendMax && !amountSats())) return;
try {
const p = await S.invoke("planSend", { to, amount: amountSats(), feeRate: Number($("feeRate").value), sendMax });
lastPlan = p;
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
$("sumAmt").textContent = fmtBch(p.recipients[0].value) + " BCH";
$("sumFee").textContent = fmtSats(p.fee) + " sat";
$("sumTotal").textContent = fmtBch(p.total) + " BCH";
if (sendMax) $("sendAmt").value = unit === "bch" ? fmtBch(p.recipients[0].value) : String(p.recipients[0].value);
$("sendBtn").disabled = false;
} catch (e) {
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
}
}
$("sendBtn").addEventListener("click", async () => {
if (!lastPlan) return;
const msg = $("sendMsg"); msg.hidden = true;
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
try {
const r = await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountSats(), feeRate: Number($("feeRate").value), sendMax });
msg.className = "msg ok";
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
msg.querySelector("a").addEventListener("click", () => openUrl(state.explorerTx + r.txid));
msg.hidden = false;
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false; $("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
lastPlan = null;
} catch (e) {
const t = cleanErr(e);
if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; }
$("sendBtn").disabled = !lastPlan;
} finally { $("sendBtn").textContent = "Send"; }
});
// ---- settings -------------------------------------------------------------- // ---- settings --------------------------------------------------------------
let settingsFilled = false; let settingsFilled = false;
function fillSettings() { function fillSettings() {