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:
parent
de576935c1
commit
821cc8e808
3 changed files with 133 additions and 1 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue