Aegis: fees that follow the network, connections that come back, its own name on Solana
Still 0.31.0 (unpublished batch). - ETH nonce. The pending count from a load-balanced RPC often misses a transaction this wallet sent seconds ago, so two sends in a row shared a nonce and the second failed or replaced the first. The nonce is taken at signing, from the RPC or from what the wallet itself last broadcast, whichever is higher, and broadcasts are serialised per wallet. - Chains without EIP-1559 (no baseFeePerGas) get a legacy EIP-155 transaction; they rejected the type-2 envelope, so a network added by a dapp could receive but never send. The tip is clamped to the fee cap. - Bitcoin and DigiByte take their fee rate from the Electrum server's estimate instead of a constant, size each output from its real script (a taproot destination was undercounted), and round the size up before pricing. Bitcoin inputs signal replace-by-fee. DigiByte keeps its 20 sat/vB floor and does not signal RBF, which it does not have. - Electrum: a wallet with live subscriptions went quiet for good when its server dropped. The client reconnects with backoff, pings to catch dead sockets, times out a silent connect, and hands the replayed subscription answers on as notifications so the wallet refreshes. dispose() ends it. - Max with an SPL token selected did nothing; it now fills the exact token balance. - Removing a wallet retired its derivation index for good. Add wallet now takes the lowest free index, so the same wallet comes back. - Solana: registered through the Wallet Standard as "Aegis" instead of setting isPhantom, with silent connect for already-connected sites. - "Only show the wallet to sites I enable": Aegis keeps the host's page-inject allow-list in step with enabled and connected sites.
This commit is contained in:
parent
2dfa9e9c3e
commit
884f3948b8
11 changed files with 479 additions and 40 deletions
|
|
@ -529,16 +529,23 @@ function migrateLegacyStorage(api) {
|
||||||
api.log("migrated legacy BCH wallet into multi-wallet layout");
|
api.log("migrated legacy BCH wallet into multi-wallet layout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The lowest index not in use. It used to be highest + 1, so removing a
|
||||||
|
// wallet retired its index for good: the keys were still derivable from the
|
||||||
|
// vault, but nothing in the UI could ever produce that wallet again, and
|
||||||
|
// whatever was on it was out of reach. Now "Add wallet" after a removal
|
||||||
|
// brings the same wallet (same keys, same address) back.
|
||||||
function nextIndex(wallets, meta) {
|
function nextIndex(wallets, meta) {
|
||||||
let max = meta.startIndex - 1;
|
const used = new Set();
|
||||||
for (const w of wallets) {
|
for (const w of wallets) {
|
||||||
if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue;
|
if (chainKey(w.chain, w.network) !== chainKey(meta.chain, meta.network)) continue;
|
||||||
if (w.isLegacy) continue;
|
if (w.isLegacy) continue;
|
||||||
const m = /\/(\d+)$/.exec(w.purpose || "");
|
const m = /\/(\d+)$/.exec(w.purpose || "");
|
||||||
const n = m ? Number(m[1]) : NaN;
|
const n = m ? Number(m[1]) : NaN;
|
||||||
if (Number.isFinite(n) && n > max) max = n;
|
if (Number.isFinite(n)) used.add(n);
|
||||||
}
|
}
|
||||||
return max + 1;
|
let i = meta.startIndex;
|
||||||
|
while (used.has(i)) i++;
|
||||||
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeWalletId(meta, index) {
|
function makeWalletId(meta, index) {
|
||||||
|
|
@ -2487,6 +2494,44 @@ function registerPanelMessages(api) {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- who can see the wallet --------------------------------------------
|
||||||
|
const injectState = () => ({
|
||||||
|
supported: !!(api.features && api.features.pageInjectPolicy),
|
||||||
|
mode: injectMode(api),
|
||||||
|
origins: injectAllowList(api),
|
||||||
|
});
|
||||||
|
api.onMessage("injectPolicyGet", (_p, m) => { fromPanel(m); return injectState(); });
|
||||||
|
api.onMessage("injectPolicySet", (p, m) => {
|
||||||
|
fromPanel(m);
|
||||||
|
if (!(api.features && api.features.pageInjectPolicy)) throw new Error("this Theseus cannot limit which sites see the wallet — update Theseus");
|
||||||
|
api.storage.set(INJECT_MODE_KEY, p && p.mode === "allowed" ? "allowed" : "all");
|
||||||
|
syncInjectPolicy(api);
|
||||||
|
return injectState();
|
||||||
|
});
|
||||||
|
// Enable one site. With no origin given it is the site in the active tab,
|
||||||
|
// which the host reports — the panel never has to type or guess a URL.
|
||||||
|
api.onMessage("injectAllowSite", async (p, m) => {
|
||||||
|
fromPanel(m);
|
||||||
|
let origin = normalizeOrigin(p && p.origin);
|
||||||
|
if (!origin && typeof api.scanActiveTabForUris === "function") {
|
||||||
|
try { origin = normalizeOrigin((await api.scanActiveTabForUris({ scheme: "wiz", limit: 1 })).origin); } catch {}
|
||||||
|
}
|
||||||
|
if (!origin) throw new Error("Open the site in a tab first — there is no web page in the active tab.");
|
||||||
|
const list = injectAllowList(api);
|
||||||
|
if (!list.includes(origin)) list.push(origin);
|
||||||
|
api.storage.set(INJECT_ALLOW_KEY, list);
|
||||||
|
syncInjectPolicy(api);
|
||||||
|
return { origin, ...injectState() };
|
||||||
|
});
|
||||||
|
api.onMessage("injectRemoveSite", (p, m) => {
|
||||||
|
fromPanel(m);
|
||||||
|
const origin = String(p && p.origin || "");
|
||||||
|
api.storage.set(INJECT_ALLOW_KEY, injectAllowList(api).filter((o) => o !== origin));
|
||||||
|
syncInjectPolicy(api);
|
||||||
|
return injectState();
|
||||||
|
});
|
||||||
|
try { syncInjectPolicy(api); } catch {}
|
||||||
|
|
||||||
api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); });
|
api.onMessage("permissions", (_p, m) => { fromPanel(m); return grantsForPanel(api); });
|
||||||
api.onMessage("revoke", (p, m) => {
|
api.onMessage("revoke", (p, m) => {
|
||||||
fromPanel(m);
|
fromPanel(m);
|
||||||
|
|
@ -2844,6 +2889,7 @@ function setGrant(api, origin, scope, value, persist) {
|
||||||
const perms = permissions(api);
|
const perms = permissions(api);
|
||||||
perms[origin] = { ...(perms[origin] || {}), ...wrap };
|
perms[origin] = { ...(perms[origin] || {}), ...wrap };
|
||||||
api.storage.set("permissions", perms);
|
api.storage.set("permissions", perms);
|
||||||
|
syncInjectPolicy(api);
|
||||||
} else {
|
} else {
|
||||||
sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap });
|
sessionGrants.set(origin, { ...(sessionGrants.get(origin) || {}), ...wrap });
|
||||||
}
|
}
|
||||||
|
|
@ -2869,6 +2915,34 @@ function revokeOrigin(api, origin) {
|
||||||
delete perms[origin];
|
delete perms[origin];
|
||||||
api.storage.set("permissions", perms);
|
api.storage.set("permissions", perms);
|
||||||
sessionGrants.delete(origin);
|
sessionGrants.delete(origin);
|
||||||
|
syncInjectPolicy(api);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- who can see the wallet ------------------------------------------------
|
||||||
|
// The bridges used to go into every https page, which tells every page that
|
||||||
|
// this browser carries a wallet (and which one) before the user has done
|
||||||
|
// anything. In "allowed" mode Theseus injects only into origins listed in
|
||||||
|
// the reserved storage key "__pageInjectPolicy" — it reads that key itself,
|
||||||
|
// at document start, even while Aegis has not been started yet. The list is
|
||||||
|
// the sites the user enabled plus every site with a saved connection, so
|
||||||
|
// turning the mode on never breaks a dapp already in use.
|
||||||
|
const INJECT_MODE_KEY = "aegis/inject/mode";
|
||||||
|
const INJECT_ALLOW_KEY = "aegis/inject/allow";
|
||||||
|
function injectAllowList(api) {
|
||||||
|
const v = api.storage.get(INJECT_ALLOW_KEY, []);
|
||||||
|
return Array.isArray(v) ? v.filter((o) => typeof o === "string") : [];
|
||||||
|
}
|
||||||
|
function injectMode(api) { return api.storage.get(INJECT_MODE_KEY, "all") === "allowed" ? "allowed" : "all"; }
|
||||||
|
function syncInjectPolicy(api) {
|
||||||
|
const origins = Array.from(new Set([...injectAllowList(api), ...Object.keys(permissions(api))]));
|
||||||
|
api.storage.set("__pageInjectPolicy", { mode: injectMode(api), origins });
|
||||||
|
}
|
||||||
|
function normalizeOrigin(raw) {
|
||||||
|
try {
|
||||||
|
const u = new URL(String(raw || "").replace(/^bns:\/\//i, "https://"));
|
||||||
|
if (u.protocol !== "https:" && u.protocol !== "http:") return null;
|
||||||
|
return `${u.protocol}//${u.host}`;
|
||||||
|
} catch { return null; }
|
||||||
}
|
}
|
||||||
// Panel view: persisted grants plus session-only ones. An origin with no
|
// Panel view: persisted grants plus session-only ones. An origin with no
|
||||||
// persisted entry is flagged `session`; one with both gets the session
|
// persisted entry is flagged `session`; one with both gets the session
|
||||||
|
|
@ -3748,6 +3822,15 @@ function registerPageMessages(api) {
|
||||||
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
|
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
|
||||||
}
|
}
|
||||||
function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); }
|
function solConnectedFor(origin) { return !!grantFor(api, origin, "sol"); }
|
||||||
|
// Is this site already connected? Lets a Wallet Standard "silent" connect
|
||||||
|
// (what adapters do on page load to restore a session) succeed without a
|
||||||
|
// prompt for a connected site and stay quiet for everyone else.
|
||||||
|
api.onMessage("sol.state", (_p, m) => {
|
||||||
|
const origin = fromPage(m);
|
||||||
|
if (!solConnectedFor(origin)) return { address: null };
|
||||||
|
try { const snap = activeSolRuntime().adapter.snapshot(); return { address: snap.address, network: snap.network }; }
|
||||||
|
catch { return { address: null }; }
|
||||||
|
});
|
||||||
api.onMessage("sol.connect", async (_p, m) => {
|
api.onMessage("sol.connect", async (_p, m) => {
|
||||||
const origin = fromPage(m);
|
const origin = fromPage(m);
|
||||||
const rt = activeSolRuntime();
|
const rt = activeSolRuntime();
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ module.exports = function makeImportedBchAdapter({
|
||||||
|
|
||||||
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }
|
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }
|
||||||
|
|
||||||
dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} }
|
dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
|
return { ImportedBchWallet, IMPORTED_BCH_NETWORKS };
|
||||||
|
|
|
||||||
|
|
@ -155,7 +155,7 @@ module.exports = function makeBchAdapter({
|
||||||
dispose() {
|
dispose() {
|
||||||
try { this._wallet.dispose(); } catch {}
|
try { this._wallet.dispose(); } catch {}
|
||||||
try { this._keys.wipe(); } catch {}
|
try { this._keys.wipe(); } catch {}
|
||||||
try { this._client.disconnect(); } catch {}
|
try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {}
|
||||||
if (this._root) this._root.fill(0);
|
if (this._root) this._root.fill(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,15 @@ module.exports = function makeBtcAdapter({
|
||||||
p2tr: 58, // 41 base + 66/4 witness (key-path)
|
p2tr: 58, // 41 base + 66/4 witness (key-path)
|
||||||
};
|
};
|
||||||
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb);
|
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb);
|
||||||
|
// Same, with each output sized from its real script (8 value + 1 length +
|
||||||
|
// script). A flat 31 vB undercounts P2TR / P2WSH destinations (43 vB), so
|
||||||
|
// a send to a bc1p… address paid below the rate it asked for.
|
||||||
|
const feeFor = (kind, nIn, outScripts, feePerVb) =>
|
||||||
|
// Round the size up before pricing it: a real transaction is a whole
|
||||||
|
// number of vbytes, and paying for 152.5 of 153 lands under the rate.
|
||||||
|
Math.ceil(Math.ceil(OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + outScripts.reduce((a, s) => a + 9 + s.length, 0)) * feePerVb);
|
||||||
|
const DEFAULT_RATE = 5; // sat/vB when the server has no estimate
|
||||||
|
const RBF_SEQUENCE = 0xfffffffd; // BIP125: opt in to replace-by-fee
|
||||||
|
|
||||||
class BtcWallet {
|
class BtcWallet {
|
||||||
constructor(root32, networkId, {
|
constructor(root32, networkId, {
|
||||||
|
|
@ -377,10 +386,32 @@ module.exports = function makeBtcAdapter({
|
||||||
this._client.setServers(this._servers);
|
this._client.setServers(this._servers);
|
||||||
}
|
}
|
||||||
|
|
||||||
plan({ to, amount, feeRate = 5, sendMax = false }) {
|
// sat/vB for confirmation within ~6 blocks, asked of the Electrum server
|
||||||
const rate = Math.min(500, Math.max(1, Number(feeRate) || 5));
|
// and remembered for a minute. The rate used to be a constant 5, which
|
||||||
|
// overpays on a quiet day and strands the payment on a busy one.
|
||||||
|
async _estimateRate() {
|
||||||
|
if (this._feeEst && Date.now() - this._feeEstAt < 60_000) return this._feeEst;
|
||||||
|
try {
|
||||||
|
const perKb = Number(await Promise.race([
|
||||||
|
this._client.call("blockchain.estimatefee", [6]),
|
||||||
|
new Promise((_, rej) => setTimeout(() => rej(new Error("estimatefee timeout")), 4000)),
|
||||||
|
]));
|
||||||
|
if (Number.isFinite(perKb) && perKb > 0) { // coin per kB; -1 = no estimate
|
||||||
|
this._feeEst = Math.min(500, Math.max(1, Math.ceil(perKb * 1e5)));
|
||||||
|
this._feeEstAt = Date.now();
|
||||||
|
return this._feeEst;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async plan({ to, amount, feeRate, sendMax = false }) {
|
||||||
|
const rate = feeRate != null && Number(feeRate) > 0
|
||||||
|
? Math.min(500, Math.max(1, Number(feeRate)))
|
||||||
|
: await this._estimateRate();
|
||||||
const dest = String(to || "");
|
const dest = String(to || "");
|
||||||
try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); }
|
let destScript;
|
||||||
|
try { destScript = bitcoinjs.address.toOutputScript(dest, this._bjsNet); }
|
||||||
catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); }
|
catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); }
|
||||||
const cur = this.current();
|
const cur = this.current();
|
||||||
if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`);
|
if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`);
|
||||||
|
|
@ -390,7 +421,7 @@ module.exports = function makeBtcAdapter({
|
||||||
if (sendMax) {
|
if (sendMax) {
|
||||||
const chosen = spendable;
|
const chosen = spendable;
|
||||||
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
||||||
const fee = feeVb(kind, chosen.length, 1, rate);
|
const fee = feeFor(kind, chosen.length, [destScript], rate);
|
||||||
if (sum <= fee) throw new Error("balance does not cover the fee");
|
if (sum <= fee) throw new Error("balance does not cover the fee");
|
||||||
return {
|
return {
|
||||||
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind,
|
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change, _kind: kind,
|
||||||
|
|
@ -404,7 +435,7 @@ module.exports = function makeBtcAdapter({
|
||||||
let sum = 0; const chosen = [];
|
let sum = 0; const chosen = [];
|
||||||
for (const u of spendable) {
|
for (const u of spendable) {
|
||||||
chosen.push(u); sum += u.value;
|
chosen.push(u); sum += u.value;
|
||||||
const withChange = feeVb(kind, chosen.length, 2, rate);
|
const withChange = feeFor(kind, chosen.length, [destScript, change.script], rate);
|
||||||
if (sum >= value + withChange) {
|
if (sum >= value + withChange) {
|
||||||
const changeVal = sum - value - withChange;
|
const changeVal = sum - value - withChange;
|
||||||
const fee = changeVal > 546 ? withChange : sum - value;
|
const fee = changeVal > 546 ? withChange : sum - value;
|
||||||
|
|
@ -435,7 +466,9 @@ module.exports = function makeBtcAdapter({
|
||||||
}
|
}
|
||||||
const psbt = new Psbt({ network: this._bjsNet });
|
const psbt = new Psbt({ network: this._bjsNet });
|
||||||
for (const u of plan._chosen) {
|
for (const u of plan._chosen) {
|
||||||
const inp = { hash: u.txid, index: u.vout };
|
// Signal replace-by-fee, so a payment sent at too low a rate can be
|
||||||
|
// re-issued at a higher one instead of sitting unconfirmed for days.
|
||||||
|
const inp = { hash: u.txid, index: u.vout, sequence: RBF_SEQUENCE };
|
||||||
const fam = u.entry.family;
|
const fam = u.entry.family;
|
||||||
if (fam === "bip44") {
|
if (fam === "bip44") {
|
||||||
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");
|
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");
|
||||||
|
|
@ -522,7 +555,7 @@ module.exports = function makeBtcAdapter({
|
||||||
dispose() {
|
dispose() {
|
||||||
clearTimeout(this._refreshTimer);
|
clearTimeout(this._refreshTimer);
|
||||||
try { this._keys.wipe(); } catch {}
|
try { this._keys.wipe(); } catch {}
|
||||||
try { this._client.disconnect(); } catch {}
|
try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {}
|
||||||
if (this._root) this._root.fill(0);
|
if (this._root) this._root.fill(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,13 @@ module.exports = function makeDgbAdapter({
|
||||||
const OUTPUT_VB = 31;
|
const OUTPUT_VB = 31;
|
||||||
const INPUT_VB = { p2pkh: 148, "p2sh-p2wpkh": 91, p2wpkh: 68, p2tr: 58 };
|
const INPUT_VB = { p2pkh: 148, "p2sh-p2wpkh": 91, p2wpkh: 68, p2tr: 58 };
|
||||||
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb);
|
const feeVb = (kind, nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + nOut * OUTPUT_VB) * feePerVb);
|
||||||
|
// Each output sized from its real script (8 value + 1 length + script); a
|
||||||
|
// flat 31 vB undercounts a dgb1p… destination.
|
||||||
|
const feeFor = (kind, nIn, outScripts, feePerVb) =>
|
||||||
|
// Round the size up before pricing it: a real transaction is a whole
|
||||||
|
// number of vbytes, and paying for 152.5 of 153 lands under the rate.
|
||||||
|
Math.ceil(Math.ceil(OVERHEAD_VB + nIn * (INPUT_VB[kind] || 68) + outScripts.reduce((a, s) => a + 9 + s.length, 0)) * feePerVb);
|
||||||
|
const DEFAULT_RATE = 20; // sat/vB floor, and the fallback
|
||||||
|
|
||||||
function scopedStorage(storage, keyPrefix) {
|
function scopedStorage(storage, keyPrefix) {
|
||||||
const k = (key) => keyPrefix + key;
|
const k = (key) => keyPrefix + key;
|
||||||
|
|
@ -355,8 +362,29 @@ module.exports = function makeDgbAdapter({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- plan + sign (via @dgb-wallet/psbt) -----------------------------
|
// ---- plan + sign (via @dgb-wallet/psbt) -----------------------------
|
||||||
plan({ to, amount, feeRate = 20, sendMax = false }) {
|
// sat/vB from the Electrum server, never below the 20 this wallet has
|
||||||
const rate = Math.min(500, Math.max(1, Number(feeRate) || 20));
|
// always paid (DigiByte's relay floor is far above Bitcoin's, and there
|
||||||
|
// is no replace-by-fee on DGB to rescue an underpaid transaction).
|
||||||
|
async _estimateRate() {
|
||||||
|
if (this._feeEst && Date.now() - this._feeEstAt < 60_000) return this._feeEst;
|
||||||
|
try {
|
||||||
|
const perKb = Number(await Promise.race([
|
||||||
|
this._client.call("blockchain.estimatefee", [6]),
|
||||||
|
new Promise((_, rej) => setTimeout(() => rej(new Error("estimatefee timeout")), 4000)),
|
||||||
|
]));
|
||||||
|
if (Number.isFinite(perKb) && perKb > 0) { // coin per kB; -1 = no estimate
|
||||||
|
this._feeEst = Math.min(500, Math.max(DEFAULT_RATE, Math.ceil(perKb * 1e5)));
|
||||||
|
this._feeEstAt = Date.now();
|
||||||
|
return this._feeEst;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return DEFAULT_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async plan({ to, amount, feeRate, sendMax = false }) {
|
||||||
|
const rate = feeRate != null && Number(feeRate) > 0
|
||||||
|
? Math.min(500, Math.max(1, Number(feeRate)))
|
||||||
|
: await this._estimateRate();
|
||||||
const dest = String(to || "");
|
const dest = String(to || "");
|
||||||
// The `payments` decoder will reject anything that isn't a valid
|
// The `payments` decoder will reject anything that isn't a valid
|
||||||
// DGB address; catch and re-raise as a sane error.
|
// DGB address; catch and re-raise as a sane error.
|
||||||
|
|
@ -367,12 +395,13 @@ module.exports = function makeDgbAdapter({
|
||||||
try { bitcoinjs.address.toOutputScript(dest, digibyte); }
|
try { bitcoinjs.address.toOutputScript(dest, digibyte); }
|
||||||
catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); }
|
catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); }
|
||||||
}
|
}
|
||||||
|
const destScript = bitcoinjs.address.toOutputScript(dest, digibyte);
|
||||||
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
||||||
const change = this._changeEntry();
|
const change = this._changeEntry();
|
||||||
if (sendMax) {
|
if (sendMax) {
|
||||||
const chosen = spendable;
|
const chosen = spendable;
|
||||||
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
||||||
const fee = feeVb(change.send, chosen.length, 1, rate);
|
const fee = feeFor(change.send, chosen.length, [destScript], rate);
|
||||||
if (sum <= fee) throw new Error("balance does not cover the fee");
|
if (sum <= fee) throw new Error("balance does not cover the fee");
|
||||||
return {
|
return {
|
||||||
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change,
|
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change,
|
||||||
|
|
@ -386,7 +415,7 @@ module.exports = function makeDgbAdapter({
|
||||||
let sum = 0; const chosen = [];
|
let sum = 0; const chosen = [];
|
||||||
for (const u of spendable) {
|
for (const u of spendable) {
|
||||||
chosen.push(u); sum += u.value;
|
chosen.push(u); sum += u.value;
|
||||||
const withChange = feeVb(change.send, chosen.length, 2, rate);
|
const withChange = feeFor(change.send, chosen.length, [destScript, change.script], rate);
|
||||||
if (sum >= value + withChange) {
|
if (sum >= value + withChange) {
|
||||||
const changeVal = sum - value - withChange;
|
const changeVal = sum - value - withChange;
|
||||||
const fee = changeVal > 546 ? withChange : sum - value;
|
const fee = changeVal > 546 ? withChange : sum - value;
|
||||||
|
|
@ -502,7 +531,7 @@ module.exports = function makeDgbAdapter({
|
||||||
dispose() {
|
dispose() {
|
||||||
clearTimeout(this._refreshTimer);
|
clearTimeout(this._refreshTimer);
|
||||||
try { this._keys.wipe(); } catch {}
|
try { this._keys.wipe(); } catch {}
|
||||||
try { this._client.disconnect(); } catch {}
|
try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {}
|
||||||
if (this._root) this._root.fill(0);
|
if (this._root) this._root.fill(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,19 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
||||||
const signedRlp = rlpEncodeList(signedFields);
|
const signedRlp = rlpEncodeList(signedFields);
|
||||||
return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp));
|
return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp));
|
||||||
}
|
}
|
||||||
|
// Legacy (type 0) transaction with EIP-155 replay protection, for chains
|
||||||
|
// that never adopted EIP-1559 (no baseFeePerGas in their blocks). Such a
|
||||||
|
// chain rejects a type-2 envelope outright, so a network added through
|
||||||
|
// wallet_addEthereumChain could receive but never send.
|
||||||
|
// sign: keccak256(RLP([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]))
|
||||||
|
// signed: RLP([nonce, gasPrice, gasLimit, to, value, data, v, r, s]), v = recid + 35 + 2·chainId
|
||||||
|
function signTxLegacy({ chainId, nonce, gasPrice, gasLimit, to, value, data }, privKey) {
|
||||||
|
const base = [nonce, gasPrice, gasLimit, to, value, data];
|
||||||
|
const hash = keccak_256(rlpEncodeList([...base, chainId, new Uint8Array(0), new Uint8Array(0)]));
|
||||||
|
const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" });
|
||||||
|
const v = BigInt(sig[0]) + 35n + 2n * BigInt(chainId);
|
||||||
|
return "0x" + toHex(rlpEncodeList([...base, v, stripLeadingZeros(sig.subarray(1, 33)), stripLeadingZeros(sig.subarray(33, 65))]));
|
||||||
|
}
|
||||||
function stripLeadingZeros(bytes) {
|
function stripLeadingZeros(bytes) {
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < bytes.length - 1 && bytes[i] === 0) i++;
|
while (i < bytes.length - 1 && bytes[i] === 0) i++;
|
||||||
|
|
@ -321,20 +334,26 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
||||||
const dataHex = normalizeData(data);
|
const dataHex = normalizeData(data);
|
||||||
const dataBytes = fromHex(dataHex.slice(2));
|
const dataBytes = fromHex(dataHex.slice(2));
|
||||||
const from = this.address.toLowerCase();
|
const from = this.address.toLowerCase();
|
||||||
const [nonceHex, priorityHex, gasPriceHex] = await Promise.all([
|
const nonceFixed = nonce != null; // the dapp chose it (e.g. to replace a pending tx)
|
||||||
nonce != null ? Promise.resolve(bigToHex(toBig(nonce))) : this._client.call("eth_getTransactionCount", [from, "pending"]),
|
const [nonceN, legacy, priorityHex, gasPriceHex] = await Promise.all([
|
||||||
|
nonceFixed ? Promise.resolve(Number(toBig(nonce))) : this._nextNonce(),
|
||||||
|
this._isLegacyChain(),
|
||||||
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
|
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
|
||||||
this._client.call("eth_gasPrice", []),
|
this._client.call("eth_gasPrice", []),
|
||||||
]);
|
]);
|
||||||
const nonceN = Number(hexToBig(nonceHex));
|
|
||||||
const tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex);
|
|
||||||
// eth_gasPrice on a 1559 chain already includes a tip, so it is the
|
// eth_gasPrice on a 1559 chain already includes a tip, so it is the
|
||||||
// best single-number estimate of what a block will actually charge.
|
// best single-number estimate of what a block will actually charge.
|
||||||
const gasPriceNow = hexToBig(gasPriceHex);
|
const gasPriceNow = hexToBig(gasPriceHex);
|
||||||
// Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise
|
// Cap: dapp-supplied maxFeePerGas (or legacy gasPrice) wins; otherwise
|
||||||
// 2 × current price + tip so the tx survives a base-fee spike.
|
// 2 × current price + tip so the tx survives a base-fee spike. A chain
|
||||||
const maxFee = maxFeePerGas != null ? toBig(maxFeePerGas)
|
// without EIP-1559 has one price and no spike to survive: 10% headroom.
|
||||||
: (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip);
|
let tip = maxPriorityFeePerGas != null ? toBig(maxPriorityFeePerGas) : hexToBig(priorityHex);
|
||||||
|
const maxFee = legacy
|
||||||
|
? (gasPrice != null ? toBig(gasPrice) : (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPriceNow * 11n) / 10n))
|
||||||
|
: (maxFeePerGas != null ? toBig(maxFeePerGas) : (gasPrice != null ? toBig(gasPrice) : gasPriceNow * 2n + tip));
|
||||||
|
// A tip above the cap is invalid ("max priority fee higher than max
|
||||||
|
// fee"). It happened whenever a dapp sent only a low gasPrice.
|
||||||
|
if (tip > maxFee) tip = maxFee;
|
||||||
const bal = BigInt(this._state.balance.confirmed || "0");
|
const bal = BigInt(this._state.balance.confirmed || "0");
|
||||||
let value = sendMax ? 0n : toBig(amount);
|
let value = sendMax ? 0n : toBig(amount);
|
||||||
if (value < 0n) throw new Error("amount must be >= 0 wei");
|
if (value < 0n) throw new Error("amount must be >= 0 wei");
|
||||||
|
|
@ -364,7 +383,9 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
||||||
_draft: {
|
_draft: {
|
||||||
chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee,
|
chainId: this._net.chainId, nonce: nonceN, maxPriorityFeePerGas: tip, maxFeePerGas: maxFee,
|
||||||
gasLimit: gas, to: dest, value, data: dataHex, accessList: [],
|
gasLimit: gas, to: dest, value, data: dataHex, accessList: [],
|
||||||
|
legacy, nonceFixed,
|
||||||
},
|
},
|
||||||
|
txType: legacy ? 0 : 2,
|
||||||
recipients: [{ to: dest, value: value.toString() }],
|
recipients: [{ to: dest, value: value.toString() }],
|
||||||
fee: feeMax.toString(), // worst case — what the balance check uses
|
fee: feeMax.toString(), // worst case — what the balance check uses
|
||||||
feeEstimate: feeEstimate.toString(), // what a block will most likely charge
|
feeEstimate: feeEstimate.toString(), // what a block will most likely charge
|
||||||
|
|
@ -377,19 +398,55 @@ module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The next nonce to use. "pending" from a load-balanced public RPC often
|
||||||
|
// does not know about a transaction this wallet broadcast seconds ago
|
||||||
|
// (it went to a different backend), so two sends in a row got the same
|
||||||
|
// nonce and the second either failed or silently replaced the first.
|
||||||
|
// What this wallet itself broadcast in the last few minutes counts too.
|
||||||
|
async _nextNonce() {
|
||||||
|
const rpc = Number(hexToBig(await this._client.call("eth_getTransactionCount", [this.address.toLowerCase(), "pending"])));
|
||||||
|
const recent = this._lastNonce != null && (Date.now() - this._lastNonceAt) < 180_000;
|
||||||
|
return recent ? Math.max(rpc, this._lastNonce + 1) : rpc;
|
||||||
|
}
|
||||||
|
// Does this chain lack EIP-1559? Decided from the latest block and
|
||||||
|
// remembered for ten minutes. Unknown (RPC error, no block) keeps the
|
||||||
|
// type-2 default that Ethereum and every major L2 accept.
|
||||||
|
async _isLegacyChain() {
|
||||||
|
if (this._legacyAt && Date.now() - this._legacyAt < 600_000) return this._legacy;
|
||||||
|
let legacy = false;
|
||||||
|
try {
|
||||||
|
const block = await this._client.call("eth_getBlockByNumber", ["latest", false]);
|
||||||
|
if (block && typeof block === "object") legacy = block.baseFeePerGas == null;
|
||||||
|
} catch { return false; }
|
||||||
|
this._legacy = legacy; this._legacyAt = Date.now();
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
async signAndBroadcast(plan) {
|
async signAndBroadcast(plan) {
|
||||||
const d = plan && plan._draft;
|
const d = plan && plan._draft;
|
||||||
if (!d) throw new Error("bad plan");
|
if (!d) throw new Error("bad plan");
|
||||||
const unsignedFields = [
|
// One broadcast at a time per wallet, and the nonce is taken at the
|
||||||
d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit,
|
// moment of signing: two approvals answered close together (or a plan
|
||||||
fromHex(d.to.slice(2)), d.value, fromHex(normalizeData(d.data).slice(2)), [],
|
// drawn before an earlier send went out) must not share one.
|
||||||
];
|
const run = async () => {
|
||||||
const rawTxHex = signTxEip1559(unsignedFields, this._priv);
|
const nonce = d.nonceFixed ? d.nonce : Math.max(d.nonce, await this._nextNonce());
|
||||||
const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]);
|
const to = fromHex(d.to.slice(2));
|
||||||
if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
const data = fromHex(normalizeData(d.data).slice(2));
|
||||||
this.log("broadcast", txid);
|
const rawTxHex = d.legacy
|
||||||
setTimeout(() => this.refresh(), 3000);
|
? signTxLegacy({ chainId: d.chainId, nonce, gasPrice: d.maxFeePerGas, gasLimit: d.gasLimit, to, value: d.value, data }, this._priv)
|
||||||
return { txid };
|
: signTxEip1559([d.chainId, nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit, to, d.value, data, []], this._priv);
|
||||||
|
const txid = await this._client.call("eth_sendRawTransaction", [rawTxHex]);
|
||||||
|
if (typeof txid !== "string" || !/^0x[0-9a-f]{64}$/i.test(txid)) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||||||
|
if (this._lastNonce == null || nonce >= this._lastNonce || Date.now() - this._lastNonceAt >= 180_000) {
|
||||||
|
this._lastNonce = nonce; this._lastNonceAt = Date.now();
|
||||||
|
}
|
||||||
|
this.log("broadcast", txid);
|
||||||
|
setTimeout(() => { if (!this._disposed) this.refresh(); }, 3000);
|
||||||
|
return { txid, nonce };
|
||||||
|
};
|
||||||
|
const next = (this._sendChain || Promise.resolve()).then(run, run);
|
||||||
|
this._sendChain = next.catch(() => {});
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid).
|
// EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid).
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore,
|
||||||
signAndBroadcast() { throw new Error("read-only"); }
|
signAndBroadcast() { throw new Error("read-only"); }
|
||||||
signMessage() { throw new Error("read-only"); }
|
signMessage() { throw new Error("read-only"); }
|
||||||
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
|
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; }
|
||||||
dispose() { clearTimeout(this._pollTimer); try { this._client.disconnect(); } catch {} }
|
dispose() { clearTimeout(this._pollTimer); try { this._client.dispose ? this._client.dispose() : this._client.disconnect(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { UtxoImportedWallet, NETWORKS };
|
return { UtxoImportedWallet, NETWORKS };
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
// fan out to `onNotify`.
|
// fan out to `onNotify`.
|
||||||
module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
const CALL_TIMEOUT_MS = 20000;
|
const CALL_TIMEOUT_MS = 20000;
|
||||||
|
const CONNECT_TIMEOUT_MS = 10000;
|
||||||
|
const PING_EVERY_MS = 60000;
|
||||||
|
const RECONNECT_MIN_MS = 2000;
|
||||||
|
const RECONNECT_MAX_MS = 60000;
|
||||||
|
|
||||||
class Connection {
|
class Connection {
|
||||||
constructor(url) {
|
constructor(url) {
|
||||||
|
|
@ -21,6 +25,11 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
const ws = new WebSocket(this.url);
|
const ws = new WebSocket(this.url);
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } };
|
const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } };
|
||||||
|
// A server that accepts the TCP connection and then says nothing
|
||||||
|
// used to hold the whole server walk hostage; give up and move on.
|
||||||
|
const connectTimer = setTimeout(() => { fail(new Error("electrum connect timeout: " + this.url)); try { ws.close(); } catch {} }, CONNECT_TIMEOUT_MS);
|
||||||
|
ws.on("open", () => clearTimeout(connectTimer));
|
||||||
|
ws.on("close", () => clearTimeout(connectTimer));
|
||||||
ws.on("open", async () => {
|
ws.on("open", async () => {
|
||||||
// A RANGE, not a flat "1.4". Fulcrum only attaches `token_data` to
|
// A RANGE, not a flat "1.4". Fulcrum only attaches `token_data` to
|
||||||
// listunspent results once protocol >= 1.5 is negotiated, and with
|
// listunspent results once protocol >= 1.5 is negotiated, and with
|
||||||
|
|
@ -93,10 +102,52 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
this.subscriptions = new Map(); // method+key -> params (replayed on reconnect)
|
this.subscriptions = new Map(); // method+key -> params (replayed on reconnect)
|
||||||
this.onNotify = null;
|
this.onNotify = null;
|
||||||
this.onServer = null; // (url|null) connection state for the UI
|
this.onServer = null; // (url|null) connection state for the UI
|
||||||
|
this.disposed = false;
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
this._reconnectDelay = RECONNECT_MIN_MS;
|
||||||
|
this._pingTimer = null;
|
||||||
}
|
}
|
||||||
setServers(servers) {
|
setServers(servers) {
|
||||||
this.servers = servers.slice();
|
this.servers = servers.slice();
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
|
this._scheduleReconnect(250); // keep the live feed going on the new list
|
||||||
|
}
|
||||||
|
// A wallet with live subscriptions used to go quiet for good when its
|
||||||
|
// server dropped: the connection was only re-opened by the next call(),
|
||||||
|
// and nothing calls while the user is just looking at a balance. Come
|
||||||
|
// back on our own, with backoff, for as long as anything is subscribed.
|
||||||
|
_scheduleReconnect(delay) {
|
||||||
|
if (this.disposed || !this.subscriptions.size || this._reconnectTimer) return;
|
||||||
|
const wait = delay != null ? delay : this._reconnectDelay;
|
||||||
|
this._reconnectTimer = setTimeout(() => {
|
||||||
|
this._reconnectTimer = null;
|
||||||
|
if (this.disposed || (this.conn && !this.conn.closed)) return;
|
||||||
|
this._ensure().then(
|
||||||
|
() => { this._reconnectDelay = RECONNECT_MIN_MS; },
|
||||||
|
() => { this._reconnectDelay = Math.min(RECONNECT_MAX_MS, this._reconnectDelay * 2); this._scheduleReconnect(); },
|
||||||
|
);
|
||||||
|
}, wait);
|
||||||
|
if (this._reconnectTimer.unref) this._reconnectTimer.unref();
|
||||||
|
}
|
||||||
|
// A half-dead socket (laptop slept, NAT dropped the flow) never fires
|
||||||
|
// "close". Ping it; a ping that fails or times out closes it, which
|
||||||
|
// lands in the reconnect path above.
|
||||||
|
_armPing(c) {
|
||||||
|
clearInterval(this._pingTimer);
|
||||||
|
this._pingTimer = setInterval(() => {
|
||||||
|
if (this.disposed || this.conn !== c || c.closed) { clearInterval(this._pingTimer); return; }
|
||||||
|
c.call("server.ping", []).catch(() => { try { c.close(); } catch {} });
|
||||||
|
}, PING_EVERY_MS);
|
||||||
|
if (this._pingTimer.unref) this._pingTimer.unref();
|
||||||
|
}
|
||||||
|
// Stop for good: no reconnects, no pings, and any later call() fails
|
||||||
|
// instead of quietly resurrecting the connection of a removed wallet.
|
||||||
|
dispose() {
|
||||||
|
this.disposed = true;
|
||||||
|
clearTimeout(this._reconnectTimer); this._reconnectTimer = null;
|
||||||
|
clearInterval(this._pingTimer); this._pingTimer = null;
|
||||||
|
this.subscriptions.clear();
|
||||||
|
this.disconnect();
|
||||||
}
|
}
|
||||||
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
|
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
|
||||||
// The protocol the live connection settled on. Callers use it to decide
|
// The protocol the live connection settled on. Callers use it to decide
|
||||||
|
|
@ -113,6 +164,7 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
return major > 1 || (major === 1 && minor >= 5);
|
return major > 1 || (major === 1 && minor >= 5);
|
||||||
}
|
}
|
||||||
async _ensure() {
|
async _ensure() {
|
||||||
|
if (this.disposed) throw new Error("electrum client disposed");
|
||||||
if (this.conn && !this.conn.closed) return this.conn;
|
if (this.conn && !this.conn.closed) return this.conn;
|
||||||
if (this.connecting) return this.connecting;
|
if (this.connecting) return this.connecting;
|
||||||
this.connecting = (async () => {
|
this.connecting = (async () => {
|
||||||
|
|
@ -120,15 +172,30 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
for (const url of this.servers) {
|
for (const url of this.servers) {
|
||||||
try {
|
try {
|
||||||
const c = await new Connection(url).connect();
|
const c = await new Connection(url).connect();
|
||||||
|
if (this.disposed) { c.close(); throw new Error("electrum client disposed"); }
|
||||||
c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); };
|
c.onNotify = (m, p) => { if (this.onNotify) this.onNotify(m, p); };
|
||||||
c.onClose = () => { if (this.conn === c) { this.conn = null; if (this.onServer) this.onServer(null); } };
|
c.onClose = () => {
|
||||||
|
if (this.conn !== c) return;
|
||||||
|
this.conn = null;
|
||||||
|
if (this.onServer) this.onServer(null);
|
||||||
|
this._scheduleReconnect();
|
||||||
|
};
|
||||||
this.conn = c;
|
this.conn = c;
|
||||||
log("connected", url);
|
log("connected", url);
|
||||||
if (this.onServer) this.onServer(url);
|
if (this.onServer) this.onServer(url);
|
||||||
// Re-arm subscriptions so a reconnect keeps the live feed.
|
this._armPing(c);
|
||||||
for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {});
|
// Re-arm subscriptions so a reconnect keeps the live feed, and
|
||||||
|
// hand each answer on as if it were a notification: whatever
|
||||||
|
// changed while we were away (a new tip, a payment) is in that
|
||||||
|
// answer, and the wallet only refreshes when it is told.
|
||||||
|
for (const [method, params] of this.subscriptions.values()) {
|
||||||
|
c.call(method, params).then((result) => {
|
||||||
|
if (!this.onNotify || this.conn !== c) return;
|
||||||
|
this.onNotify(method, method === "blockchain.headers.subscribe" ? [result] : [...params, result]);
|
||||||
|
}, () => {});
|
||||||
|
}
|
||||||
return c;
|
return c;
|
||||||
} catch (e) { lastErr = e; log("failed", url, e?.message); }
|
} catch (e) { lastErr = e; log("failed", url, e?.message); if (this.disposed) break; }
|
||||||
}
|
}
|
||||||
throw lastErr || new Error("no electrum server reachable");
|
throw lastErr || new Error("no electrum server reachable");
|
||||||
})();
|
})();
|
||||||
|
|
@ -146,6 +213,7 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||||
}
|
}
|
||||||
clearSubscriptions() { this.subscriptions.clear(); }
|
clearSubscriptions() { this.subscriptions.clear(); }
|
||||||
disconnect() {
|
disconnect() {
|
||||||
|
clearInterval(this._pingTimer); this._pingTimer = null;
|
||||||
if (this.conn) { const c = this.conn; this.conn = null; c.close(); }
|
if (this.conn) { const c = this.conn; this.conn = null; c.close(); }
|
||||||
if (this.onServer) this.onServer(null);
|
if (this.onServer) this.onServer(null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1330,6 +1330,21 @@
|
||||||
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
|
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
|
||||||
<div id="sites" class="kv"></div>
|
<div id="sites" class="kv"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="lbl">Who can see the wallet</div>
|
||||||
|
<div class="hint">A site can tell a wallet is installed before you connect to it. Limit that to the sites you choose and every other page sees a browser with no wallet at all.</div>
|
||||||
|
<label class="chk" style="display:flex;gap:8px;align-items:center;margin:8px 0">
|
||||||
|
<input type="checkbox" id="injectOnlyAllowed"> <span>Only show the wallet to sites I enable</span>
|
||||||
|
</label>
|
||||||
|
<div id="injectUnsupported" class="hint" hidden>This needs a newer Theseus. Until then the wallet is visible to every site.</div>
|
||||||
|
<div id="injectAllowBox" hidden>
|
||||||
|
<div class="actions" style="justify-content:flex-start;gap:6px;margin-bottom:6px">
|
||||||
|
<button class="btn sm" id="injectEnableCurrent" type="button">Enable on the open site</button>
|
||||||
|
</div>
|
||||||
|
<div class="msg" id="injectMsg" hidden></div>
|
||||||
|
<div id="injectSites" class="kv"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div><!-- /sites section -->
|
</div><!-- /sites section -->
|
||||||
|
|
||||||
<div data-setsec-body="about" hidden>
|
<div data-setsec-body="about" hidden>
|
||||||
|
|
|
||||||
|
|
@ -4446,6 +4446,19 @@ function flash(btn, text) {
|
||||||
// ---- send ------------------------------------------------------------------
|
// ---- send ------------------------------------------------------------------
|
||||||
|
|
||||||
$("sendMax").addEventListener("click", () => {
|
$("sendMax").addEventListener("click", () => {
|
||||||
|
// A token has no "max mode": its fee is paid in the coin, so Max is just
|
||||||
|
// the whole token balance, written into the field exactly. The toggle
|
||||||
|
// below did nothing for tokens — the plan never received it, and Send
|
||||||
|
// went out with whatever sat in the disabled field.
|
||||||
|
if (sendAsset) {
|
||||||
|
const t = (sel()?.tokens || []).find((x) => x.mint === sendAsset.mint);
|
||||||
|
sendMax = false;
|
||||||
|
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
|
||||||
|
if (t) $("sendAmt").value = unit === "big" ? unitsToDecimalText(String(t.balance), amountDecimals()) : String(t.balance);
|
||||||
|
updateSendFiatPreview();
|
||||||
|
schedulePlan();
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendMax = !sendMax;
|
sendMax = !sendMax;
|
||||||
$("sendMax").classList.toggle("primary", sendMax);
|
$("sendMax").classList.toggle("primary", sendMax);
|
||||||
$("sendAmt").disabled = sendMax;
|
$("sendAmt").disabled = sendMax;
|
||||||
|
|
@ -5455,7 +5468,46 @@ function renderPricesSetting() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "Who can see the wallet". The list is the sites the user enabled by hand;
|
||||||
|
// sites with a saved connection stay visible regardless and are listed under
|
||||||
|
// Connected sites above.
|
||||||
|
async function renderInjectPolicy() {
|
||||||
|
const box = $("injectOnlyAllowed");
|
||||||
|
if (!box) return;
|
||||||
|
let st;
|
||||||
|
try { st = await S.invoke("injectPolicyGet"); } catch { return; }
|
||||||
|
box.checked = st.mode === "allowed";
|
||||||
|
box.disabled = !st.supported;
|
||||||
|
$("injectUnsupported").hidden = !!st.supported;
|
||||||
|
$("injectAllowBox").hidden = !(st.supported && st.mode === "allowed");
|
||||||
|
const list = $("injectSites");
|
||||||
|
list.innerHTML = (st.origins || []).length
|
||||||
|
? st.origins.map((o) => `<div class="k" style="overflow-wrap:anywhere">${esc(o)}</div><div class="v"><button class="btn sm" data-injremove="${esc(o)}" type="button">Remove</button></div>`).join("")
|
||||||
|
: `<div class="hint">No sites enabled yet. Open the site in a tab, then press the button above.</div>`;
|
||||||
|
list.querySelectorAll("[data-injremove]").forEach((b) => b.addEventListener("click", async () => {
|
||||||
|
try { await S.invoke("injectRemoveSite", { origin: b.dataset.injremove }); } catch {}
|
||||||
|
renderInjectPolicy();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if ($("injectOnlyAllowed")) {
|
||||||
|
$("injectOnlyAllowed").addEventListener("change", async (e) => {
|
||||||
|
try { await S.invoke("injectPolicySet", { mode: e.target.checked ? "allowed" : "all" }); }
|
||||||
|
catch (err) { e.target.checked = !e.target.checked; aegisAlert("Could not save setting: " + cleanErr(err)); }
|
||||||
|
renderInjectPolicy();
|
||||||
|
});
|
||||||
|
$("injectEnableCurrent").addEventListener("click", async () => {
|
||||||
|
const msg = $("injectMsg");
|
||||||
|
try {
|
||||||
|
const r = await S.invoke("injectAllowSite", {});
|
||||||
|
msg.className = "msg ok"; msg.textContent = `Enabled on ${r.origin}. Reload that page to use the wallet there.`;
|
||||||
|
} catch (err) { msg.className = "msg err"; msg.textContent = cleanErr(err); }
|
||||||
|
msg.hidden = false;
|
||||||
|
renderInjectPolicy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function renderSites() {
|
async function renderSites() {
|
||||||
|
renderInjectPolicy();
|
||||||
let perms = {};
|
let perms = {};
|
||||||
try { perms = await S.invoke("permissions"); } catch {}
|
try { perms = await S.invoke("permissions"); } catch {}
|
||||||
const origins = Object.keys(perms).filter((o) => {
|
const origins = Object.keys(perms).filter((o) => {
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,7 @@ const mainWorldSource = `(function () {
|
||||||
solState.publicKey = makePubkey(r.address);
|
solState.publicKey = makePubkey(r.address);
|
||||||
window.solana.publicKey = solState.publicKey;
|
window.solana.publicKey = solState.publicKey;
|
||||||
window.solana.isConnected = true;
|
window.solana.isConnected = true;
|
||||||
|
wsSetAccount(r.address);
|
||||||
for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} }
|
for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} }
|
||||||
return { publicKey: solState.publicKey };
|
return { publicKey: solState.publicKey };
|
||||||
}
|
}
|
||||||
|
|
@ -452,8 +453,108 @@ const mainWorldSource = `(function () {
|
||||||
solState.publicKey = null;
|
solState.publicKey = null;
|
||||||
window.solana.publicKey = null;
|
window.solana.publicKey = null;
|
||||||
window.solana.isConnected = false;
|
window.solana.isConnected = false;
|
||||||
|
wsSetAccount(null);
|
||||||
for (const fn of solListeners.disconnect) { try { fn(); } catch {} }
|
for (const fn of solListeners.disconnect) { try { fn(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Wallet Standard (how Solana dapps find wallets now) -----------------
|
||||||
|
// Aegis used to get picked by setting isPhantom: true — claiming to be a
|
||||||
|
// different wallet, which shows Phantom's name and icon for Aegis and
|
||||||
|
// breaks any dapp that then relies on Phantom-only behaviour. Registering
|
||||||
|
// as "Aegis" through the Wallet Standard puts it in every wallet-adapter
|
||||||
|
// list under its own name instead.
|
||||||
|
const SOL_CHAINS = ["solana:mainnet", "solana:devnet"];
|
||||||
|
const SOL_FEATURES = ["solana:signAndSendTransaction", "solana:signTransaction", "solana:signMessage"];
|
||||||
|
let wsAccounts = [];
|
||||||
|
const wsChange = [];
|
||||||
|
function wsSetAccount(base58) {
|
||||||
|
wsAccounts = base58 ? [Object.freeze({ address: base58, publicKey: base58ToBytes(base58), chains: SOL_CHAINS, features: SOL_FEATURES })] : [];
|
||||||
|
for (const fn of wsChange.slice()) { try { fn({ accounts: wsAccounts }); } catch {} }
|
||||||
|
}
|
||||||
|
// Wire layout: compact-u16(sigCount) || sig[64]… || message. Returns where
|
||||||
|
// the signatures and the message start.
|
||||||
|
function splitWire(wire) {
|
||||||
|
let off = 0, n = 0, shift = 0;
|
||||||
|
for (;;) { const b = wire[off++]; n |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; if (shift > 14) throw new Error("Aegis: bad transaction"); }
|
||||||
|
return { sigCount: n, sigsStart: off, messageStart: off + n * 64 };
|
||||||
|
}
|
||||||
|
// Put our signature into our slot: the slot index is our key's position
|
||||||
|
// among the message's signer keys (legacy and v0 alike).
|
||||||
|
function patchSignature(wire, pubBytes, sig) {
|
||||||
|
const { sigCount, sigsStart, messageStart } = splitWire(wire);
|
||||||
|
let m = messageStart;
|
||||||
|
if (wire[m] & 0x80) m++; // v0 version prefix
|
||||||
|
m += 3; // header
|
||||||
|
let keyCount = 0, shift = 0;
|
||||||
|
for (;;) { const b = wire[m++]; keyCount |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; }
|
||||||
|
for (let i = 0; i < Math.min(keyCount, sigCount); i++) {
|
||||||
|
let eq = true;
|
||||||
|
for (let j = 0; j < 32; j++) if (wire[m + i * 32 + j] !== pubBytes[j]) { eq = false; break; }
|
||||||
|
if (eq) { const out = new Uint8Array(wire); out.set(sig, sigsStart + i * 64); return out; }
|
||||||
|
}
|
||||||
|
throw new Error("Aegis: this wallet is not a signer of the transaction");
|
||||||
|
}
|
||||||
|
const AEGIS_ICON = "data:image/svg+xml;base64," + btoa("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><polygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='#0a0a0d' stroke='#d6ff3d' stroke-width='2.5'/><circle cx='16' cy='16' r='4.3' fill='none' stroke='#d6ff3d' stroke-width='1.6'/><circle cx='16' cy='16' r='1.6' fill='#d6ff3d'/></svg>");
|
||||||
|
const standardWallet = {
|
||||||
|
version: "1.0.0",
|
||||||
|
name: "Aegis",
|
||||||
|
icon: AEGIS_ICON,
|
||||||
|
chains: SOL_CHAINS,
|
||||||
|
get accounts() { return wsAccounts; },
|
||||||
|
features: {
|
||||||
|
"standard:connect": { version: "1.0.0", connect: async (input) => {
|
||||||
|
if (input && input.silent) {
|
||||||
|
// Restore a session, never prompt.
|
||||||
|
if (!wsAccounts.length) {
|
||||||
|
const s = await invoke("sol.state").catch(() => null);
|
||||||
|
if (s && s.address) { solState.publicKey = makePubkey(s.address); solana.publicKey = solState.publicKey; solana.isConnected = true; wsSetAccount(s.address); }
|
||||||
|
}
|
||||||
|
return { accounts: wsAccounts };
|
||||||
|
}
|
||||||
|
await solConnect();
|
||||||
|
return { accounts: wsAccounts };
|
||||||
|
} },
|
||||||
|
"standard:disconnect": { version: "1.0.0", disconnect: async () => { await solDisconnect(); } },
|
||||||
|
"standard:events": { version: "1.0.0", on: (event, fn) => {
|
||||||
|
if (event !== "change" || typeof fn !== "function") return () => {};
|
||||||
|
wsChange.push(fn);
|
||||||
|
return () => { const i = wsChange.indexOf(fn); if (i >= 0) wsChange.splice(i, 1); };
|
||||||
|
} },
|
||||||
|
"solana:signMessage": { version: "1.1.0", signMessage: async (...inputs) => {
|
||||||
|
const out = [];
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const message = inp.message instanceof Uint8Array ? inp.message : new Uint8Array(inp.message || []);
|
||||||
|
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(message) });
|
||||||
|
out.push({ signedMessage: message, signature: base58ToBytes(r.signature) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} },
|
||||||
|
"solana:signTransaction": { version: "1.0.0", supportedTransactionVersions: ["legacy", 0], signTransaction: async (...inputs) => {
|
||||||
|
const out = [];
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const wire = inp.transaction instanceof Uint8Array ? inp.transaction : new Uint8Array(inp.transaction || []);
|
||||||
|
const { messageStart } = splitWire(wire);
|
||||||
|
const r = await invoke("sol.signTransaction", { messageB64: u8ToBase64(wire.subarray(messageStart)) });
|
||||||
|
out.push({ signedTransaction: patchSignature(wire, base58ToBytes(r.publicKey || r.address), base58ToBytes(r.signature)) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} },
|
||||||
|
"solana:signAndSendTransaction": { version: "1.0.0", supportedTransactionVersions: ["legacy", 0], signAndSendTransaction: async (...inputs) => {
|
||||||
|
const out = [];
|
||||||
|
for (const inp of inputs) {
|
||||||
|
const wire = inp.transaction instanceof Uint8Array ? inp.transaction : new Uint8Array(inp.transaction || []);
|
||||||
|
const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(wire) });
|
||||||
|
out.push({ signature: base58ToBytes(r.txid) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
function registerStandardWallet() {
|
||||||
|
const callback = (api) => { try { api.register(standardWallet); } catch {} };
|
||||||
|
try { window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet", { detail: callback })); } catch {}
|
||||||
|
try { window.addEventListener("wallet-standard:app-ready", (e) => callback(e.detail)); } catch {}
|
||||||
|
}
|
||||||
async function solSignMessage(u8) {
|
async function solSignMessage(u8) {
|
||||||
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []);
|
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []);
|
||||||
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) });
|
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) });
|
||||||
|
|
@ -491,7 +592,7 @@ const mainWorldSource = `(function () {
|
||||||
}
|
}
|
||||||
const solana = {
|
const solana = {
|
||||||
isAegis: true,
|
isAegis: true,
|
||||||
isPhantom: true, // set so dapps that gate on isPhantom pick us
|
// No isPhantom: Aegis announces itself as Aegis (Wallet Standard, below).
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
publicKey: null,
|
publicKey: null,
|
||||||
connect: async (opts) => solConnect(opts),
|
connect: async (opts) => solConnect(opts),
|
||||||
|
|
@ -537,6 +638,7 @@ const mainWorldSource = `(function () {
|
||||||
// load — MetaMask does the same trick.
|
// load — MetaMask does the same trick.
|
||||||
try { Object.defineProperty(window, "ethereum", { value: ethereum, writable: false, configurable: false }); } catch { window.ethereum = ethereum; }
|
try { Object.defineProperty(window, "ethereum", { value: ethereum, writable: false, configurable: false }); } catch { window.ethereum = ethereum; }
|
||||||
try { Object.defineProperty(window, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; }
|
try { Object.defineProperty(window, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; }
|
||||||
|
registerStandardWallet();
|
||||||
// EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis.
|
// EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis.
|
||||||
try {
|
try {
|
||||||
const info = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
|
const info = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue