feat(theseus/bchwallet): per-site payment allowance for remembered sends

The dapp send approval gains an "Afterwards" dropdown: ask every time, or
allow up to 0.001 / 0.01 / 0.1 BCH more without asking. The allowance is
stored as permissions[origin].sendTx {capSats, usedSats}; sends within the
remainder go through silently and draw it down, a larger request re-prompts
(showing what is left) and the choice made there replaces the allowance.
No unlimited option. Settings > Connected sites shows the remaining budget
and Revoke clears it. Message signing still asks every time.

Host: approvalModal accepts `select` {id, label, options}; a chosen value
comes back as "+<id>=<value>" and is validated against the offered options.
This commit is contained in:
Local Dev 2026-09-06 12:43:17 +02:00
parent a7cbc42dfa
commit 40391798e0
7 changed files with 64 additions and 11 deletions

View file

@ -320,7 +320,9 @@ class AddonHost {
},
// approval-modal: ask the user. Resolves to the chosen action id, or
// "cancel" (Escape / mask click / window closed). With `checkbox` set
// and ticked, the id comes back suffixed "+<checkbox.id>".
// and ticked, the id comes back suffixed "+<checkbox.id>"; with
// `select` {id, label, options:[{value,label}]} and a non-empty value
// chosen, "+<select.id>=<value>".
approvalModal: async (opts) => {
if (!manifest.capabilities.includes("approval-modal")) {
throw new Error(`add-on "${manifest.id}" must declare the "approval-modal" capability in addon.json`);

View file

@ -5,5 +5,5 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("approval", {
onShow: (cb) => ipcRenderer.on("approval-show", (_e, req) => cb(req)),
pick: (reqId, action, checked) => ipcRenderer.invoke("approval-pick", reqId, action, !!checked),
pick: (reqId, action, checked, extra) => ipcRenderer.invoke("approval-pick", reqId, action, !!checked, String(extra || "")),
});

View file

@ -36,6 +36,8 @@
.rows .v.mono { font: 12.5px/1.4 ui-monospace, "Cascadia Code", Consolas, monospace; }
.rows .v.strong { font-weight: 650; font-size: 14px; }
label.chk { display: flex; align-items: center; gap: 8px; color: var(--mut); font-size: 12.5px; margin-bottom: 12px; cursor: pointer; }
label.chk select { flex: 1; padding: 5px 8px; border-radius: 6px; background: var(--surface2); color: var(--ink);
border: 1px solid var(--line); font: inherit; font-size: 12.5px; }
.pact { display: flex; gap: 6px; justify-content: flex-end; }
.pbtn { padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface2);
color: var(--ink); cursor: pointer; font: inherit; font-size: 12.5px; }
@ -52,9 +54,10 @@
function finish(action, checked) {
if (!current) return;
const { reqId } = current;
const extra = document.getElementById("sel") ? document.getElementById("sel").value : "";
current = null;
document.body.innerHTML = "";
window.approval.pick(reqId, action, checked);
window.approval.pick(reqId, action, checked, extra);
}
window.approval.onShow((req) => {
current = req;
@ -69,6 +72,7 @@
${req.body ? `<div class="body">${esc(req.body)}</div>` : ""}
${rows.length ? `<div class="rows">${rows.map((r) => `<div class="k">${esc(r.label)}</div><div class="v${r.mono ? " mono" : ""}${r.strong ? " strong" : ""}">${esc(r.value)}</div>`).join("")}</div>` : ""}
${req.checkbox ? `<label class="chk"><input type="checkbox" id="chk"> ${esc(req.checkbox.label || "Always allow")}</label>` : ""}
${req.select ? `<label class="chk"><span>${esc(req.select.label)}</span><select id="sel">${req.select.options.map((o) => `<option value="${esc(o.value)}">${esc(o.label)}</option>`).join("")}</select></label>` : ""}
<div class="pact">
${hasCancel ? "" : `<button class="pbtn" type="button" data-id="cancel">Cancel</button>`}
${actions.map((a) => `<button class="pbtn${a.primary ? " primary" : ""}${a.danger ? " danger" : ""}" type="button" data-id="${esc(a.id)}">${esc(a.label || a.id)}</button>`).join("")}

View file

@ -177,8 +177,12 @@ function registerPanelMessages(api) {
}
// ---- dapp bridge (window.bitcoincash) ---------------------------------------
// Permissions live in storage as { [origin]: { readAddress: true } }. Only
// readAddress can be remembered; signing and sending ask every time.
// Permissions live in storage as
// { [origin]: { readAddress: true, sendTx: { capSats, usedSats, grantedAt } } }
// readAddress is a plain grant. sendTx is an allowance the user picks in the
// send approval; silent sends draw it down and a request over the remainder
// asks again. There is no "unlimited" option. Message signing always asks.
const ALLOWANCES = [100000, 1000000, 10000000]; // 0.001, 0.01, 0.1 BCH
const pendingByOrigin = new Set();
function permissions(api) { const p = api.storage.get("permissions", {}); return p && typeof p === "object" ? p : {}; }
function fromPage(m) {
@ -230,6 +234,19 @@ function registerPageMessages(api) {
catch (e) { throw new Error(/insufficient funds|too small/i.test(e?.message) ? "insufficient funds" : e?.message || String(e)); }
const d = describePlan(plan);
if (d.recipients.length > 8) throw new Error("too many outputs");
// Remembered budget: a site the user granted an allowance may spend
// silently until it is used up; anything larger re-prompts.
const perms = permissions(api);
const budget = perms[origin] && perms[origin].sendTx;
const remaining = budget ? Math.max(0, (budget.capSats | 0) - (budget.usedSats | 0)) : 0;
if (budget && d.total <= remaining) {
const r = await c.wallet.signAndBroadcast(plan);
budget.usedSats = (budget.usedSats | 0) + d.total;
api.storage.set("permissions", perms);
emitState();
api.log(`silent send ${d.total} sat for ${origin}, ${remaining - d.total} sat of allowance left`);
return { txid: r.txid };
}
const rows = d.recipients.map((r, i) => ({ label: d.recipients.length > 1 ? `To #${i + 1}` : "To", value: r.to, mono: true }));
rows.push({ label: "Amount", value: fmtBch(d.recipients.reduce((a, r) => a + r.value, 0)) + " BCH", strong: true });
rows.push({ label: "Fee", value: `${d.fee} sat (${d.feeRate} sat/B)` });
@ -237,11 +254,28 @@ function registerPageMessages(api) {
const pick = await api.approvalModal({
title: "Send Bitcoin Cash?",
origin,
body: "This site is asking your wallet to pay. Check the address and amount.",
body: budget
? `This payment is over what is left of the site's allowance (${fmtBch(remaining)} BCH). Check the address and amount.`
: "This site is asking your wallet to pay. Check the address and amount.",
rows,
actions: [{ id: "send", label: "Send", primary: true }],
select: {
id: "cap", label: "Afterwards",
options: [{ value: "", label: "ask every time" }, ...ALLOWANCES.map((s) => ({ value: String(s), label: `allow up to ${fmtBch(s)} BCH more without asking` }))],
},
});
if (pick !== "send") throw new Error("user rejected");
const [action, ...flags] = pick.split("+");
if (action !== "send") throw new Error("user rejected");
const cap = flags.find((f) => f.startsWith("cap="));
const capSats = cap ? Number(cap.slice(4)) : 0;
if (ALLOWANCES.includes(capSats)) {
perms[origin] = { ...(perms[origin] || {}), sendTx: { capSats, usedSats: 0, grantedAt: Date.now() } };
api.storage.set("permissions", perms);
} else if (budget) {
delete perms[origin].sendTx;
api.storage.set("permissions", perms);
}
emitState();
const r = await c.wallet.signAndBroadcast(plan);
return { txid: r.txid };
});

View file

@ -176,7 +176,7 @@
</div>
<div class="card" style="margin-top:16px">
<div class="lbl">Connected sites</div>
<div class="hint">Sites allowed to read your address without asking. Payments and message signing always ask.</div>
<div class="hint">Sites allowed to read your address, and sites with a payment allowance they can spend without asking. Message signing always asks.</div>
<div id="sites" class="kv"></div>
</div>
<div class="msg err" id="settingsMsg" hidden></div>

View file

@ -198,10 +198,15 @@ function fillSettings() {
async function renderSites() {
let perms = {};
try { perms = await S.invoke("permissions"); } catch {}
const origins = Object.keys(perms).filter((o) => perms[o] && perms[o].readAddress);
const origins = Object.keys(perms).filter((o) => perms[o] && (perms[o].readAddress || perms[o].sendTx));
const el = $("sites");
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
el.innerHTML = origins.map((o) => `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div class="mono">${esc(o)}</div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`).join("");
el.innerHTML = origins.map((o) => {
const p = perms[o]; const what = [];
if (p.readAddress) what.push("address");
if (p.sendTx) what.push(`payments: ${fmtBch(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)))} of ${fmtBch(p.sendTx.capSats)} BCH left`);
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
}).join("");
el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => {
try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {}
}));

10
main.js
View file

@ -2377,13 +2377,18 @@ function showApprovalModal(opts, addonId) {
rows: Array.isArray(opts.rows) ? opts.rows.map((r) => ({ label: String(r.label ?? ""), value: String(r.value ?? ""), mono: !!r.mono, strong: !!r.strong })) : [],
actions: Array.isArray(opts.actions) ? opts.actions.map((x) => ({ id: String(x.id), label: String(x.label || x.id), primary: !!x.primary, danger: !!x.danger })) : [],
checkbox: opts.checkbox ? { id: String(opts.checkbox.id || "always"), label: String(opts.checkbox.label || "Always allow") } : null,
// Optional dropdown; a non-empty chosen value comes back as "+<id>=<value>".
select: opts.select && Array.isArray(opts.select.options) ? {
id: String(opts.select.id || "choice"), label: String(opts.select.label || ""),
options: opts.select.options.map((o) => ({ value: String(o.value ?? ""), label: String(o.label ?? o.value ?? "") })),
} : null,
};
return new Promise((resolve) => {
approvalQueue.push({ req, resolve });
pumpApproval();
});
}
ipcMain.handle("approval-pick", (e, reqId, action, checked) => {
ipcMain.handle("approval-pick", (e, reqId, action, checked, extra) => {
if (!approvalPop || e.sender !== approvalPop.webContents) return false;
if (!approvalCurrent || approvalCurrent.req.reqId !== reqId) return false;
const cur = approvalCurrent;
@ -2391,6 +2396,9 @@ ipcMain.handle("approval-pick", (e, reqId, action, checked) => {
approvalPop.setVisible(false);
let result = String(action || "cancel");
if (result !== "cancel" && checked && cur.req.checkbox) result += "+" + cur.req.checkbox.id;
if (result !== "cancel" && cur.req.select && extra && cur.req.select.options.some((o) => o.value === extra)) {
result += "+" + cur.req.select.id + "=" + extra;
}
cur.resolve(result);
pumpApproval();
return true;