diff --git a/addons-host.js b/addons-host.js index 909da10..e995d4d 100644 --- a/addons-host.js +++ b/addons-host.js @@ -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 "+". + // and ticked, the id comes back suffixed "+"; with + // `select` {id, label, options:[{value,label}]} and a non-empty value + // chosen, "+=". 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`); diff --git a/approval-preload.js b/approval-preload.js index b861e17..b1e539d 100644 --- a/approval-preload.js +++ b/approval-preload.js @@ -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 || "")), }); diff --git a/approval.html b/approval.html index 8b7c4bc..171351c 100644 --- a/approval.html +++ b/approval.html @@ -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 ? `
${esc(req.body)}
` : ""} ${rows.length ? `
${rows.map((r) => `
${esc(r.label)}
${esc(r.value)}
`).join("")}
` : ""} ${req.checkbox ? `` : ""} + ${req.select ? `` : ""}
${hasCancel ? "" : ``} ${actions.map((a) => ``).join("")} diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index e658650..434c825 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -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 }; }); diff --git a/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index 537d2f8..e2d5f91 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -176,7 +176,7 @@
Connected sites
-
Sites allowed to read your address without asking. Payments and message signing always ask.
+
Sites allowed to read your address, and sites with a payment allowance they can spend without asking. Message signing always asks.
diff --git a/bundled-addons/bchwallet/panel.js b/bundled-addons/bchwallet/panel.js index cb46424..eded93c 100644 --- a/bundled-addons/bchwallet/panel.js +++ b/bundled-addons/bchwallet/panel.js @@ -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 = `
None yet.
`; return; } - el.innerHTML = origins.map((o) => `
${esc(o)}
`).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 `
${esc(o)}
${esc(what.join(" ยท "))}
`; + }).join(""); el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => { try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {} })); diff --git a/main.js b/main.js index 875f364..4a3f102 100644 --- a/main.js +++ b/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 "+=". + 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;