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:
parent
a7cbc42dfa
commit
40391798e0
7 changed files with 64 additions and 11 deletions
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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 || "")),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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("")}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
10
main.js
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue