Aegis: sends name their wallet, amounts with spaces are refused, BCMR cleanup

- send, sendToken and consolidate now require the wallet id the panel
  reviewed them for; a missing id used to skip the check, and the Solana
  token send sent none, so a selection change under the PIN pad sent from
  another wallet.
- "1 0" was read as 10: a space inside an amount is now refused.
- A BCMR registry list saved before https was enforced is filtered on read,
  and names lose the soft hyphen, Arabic letter mark, Mongolian vowel
  separator, line/paragraph separators and Unicode tag characters too.
This commit is contained in:
Local Dev 2026-10-04 02:26:15 +02:00
parent 7447d57e96
commit 916a748889
3 changed files with 23 additions and 8 deletions

View file

@ -1806,6 +1806,10 @@ function registerPanelMessages(api) {
api.onMessage("sendToken", async (p, m) => {
fromPanel(m);
requirePanelTxPin();
// Same rule as send: the token send names the wallet it was reviewed for.
if (!p || !p.walletId || String(p.walletId) !== selectedWalletId()) {
throw new Error("the selected wallet changed — review the send and try again");
}
const rt = requireSelected();
if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only");
const plan = await rt.adapter.planTokenTransfer(p || {});
@ -1834,7 +1838,10 @@ function registerPanelMessages(api) {
// The panel names the wallet its form was built for. If the selection
// moved since (another surface, a late state push), refuse rather than
// send this amount from a different wallet.
if (p && p.walletId && String(p.walletId) !== selectedWalletId()) {
// Required, not optional: a send that does not say which wallet it was
// reviewed for cannot be checked against the selection.
if (!p || !p.walletId) throw new Error("send names no wallet — review the send and try again");
if (String(p.walletId) !== selectedWalletId()) {
throw new Error("the selected wallet changed — review the send and try again");
}
const rt = requireSelected();
@ -1936,7 +1943,7 @@ function registerPanelMessages(api) {
// The destination is the wallet the PREVIEW was drawn for, not whatever
// is selected by the time the button is pressed: a preview painted for A
// followed by a switch to B used to sweep everything into B.
if (p && p.destinationWalletId && String(p.destinationWalletId) !== destId) {
if (!p || !p.destinationWalletId || String(p.destinationWalletId) !== destId) {
throw new Error("the selected wallet changed since this preview — reopen Consolidate");
}
const destEntry = walletEntries().find((w) => w.id === destId);

View file

@ -46,8 +46,11 @@ const DEFAULT_REGISTRIES = [
module.exports = function makeBcmr({ storage, log = () => {} }) {
function registryList() {
// Filtered on read as well: a list saved before setRegistries enforced
// https still carries its http entries.
const custom = storage.get("bcmr/registries", null);
if (Array.isArray(custom) && custom.length) return custom;
const clean = Array.isArray(custom) ? custom.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : [];
if (clean.length) return clean;
return DEFAULT_REGISTRIES.slice();
}
function setRegistries(list) {
@ -164,10 +167,13 @@ module.exports = function makeBcmr({ storage, log = () => {} }) {
//
// A local name wins over the registry: the user typed it for this exact
// category, which is better evidence than a third-party document.
// Control, bidi-override, zero-width and BOM characters. Built from code
// Control, bidi-override, zero-width and BOM characters, plus the soft
// hyphen, the Arabic letter mark, the Mongolian vowel separator, the
// line/paragraph separators and the Unicode tag block. Built from code
// points so no invisible character has to live in this source file.
const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b);
const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g");
const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b);
const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0xad, 0xad) + rng(0x61c, 0x61c) + rng(0x180e, 0x180e)
+ rng(0x200b, 0x200f) + rng(0x2028, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + rng(0xe0000, 0xe007f) + "]", "gu");
function cleanText(v, max) {
if (typeof v !== "string") return null;
const s = v.replace(INVISIBLE, "").trim().slice(0, max);

View file

@ -4135,8 +4135,10 @@ function amountDecimals() { return sendAsset ? Number(sendAsset.decimals) || 0 :
// guess: the old `.replace(/,/g, "")` turned a decimal comma ("0,5") into 5,
// and Number() accepted "1e3", "0x10" and "-0.5".
function parseAmountText(text) {
let raw = String(text == null ? "" : text).trim().replace(/\s/g, "");
let raw = String(text == null ? "" : text).trim();
if (!raw) return { empty: true };
// Space inside the number is refused, not deleted: "1 0" used to become 10.
if (/\s/.test(raw)) return { error: "Remove the space from the amount" };
if (/^\d{1,3}(,\d{3})+\.\d*$/.test(raw) || /^\d{1,3}(,\d{3}){2,}$/.test(raw)) {
raw = raw.replace(/,/g, ""); // 1,234.5 · 1,234,567
} else if (/^\d{1,3},\d{3}$/.test(raw)) {
@ -4610,7 +4612,7 @@ $("sendBtn").addEventListener("click", async () => {
try {
const isToken = !!(req.mint && lastPlan._token);
const r = isToken
? await S.invoke("sendToken", { mint: req.mint, to: req.to, amount: req.amount })
? await S.invoke("sendToken", { walletId: req.walletId, mint: req.mint, to: req.to, amount: req.amount })
: await S.invoke("send", { walletId: req.walletId, to: req.to, amount: req.amount, feeRate: req.feeRate, sendMax: req.sendMax, memo: req.memo });
// A broadcast that returned no txid is still a broadcast: never report
// it as a failure and leave a filled form inviting a second send.