Setup 5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643 Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4 Bundled since 0.3.27: 32d4b75 - Aegis (bchwallet) gains its own update card in Settings > General beside Ariadne. Check for updates hits the same signed OTA endpoint the boot timer uses; Restart to apply appears when a signed newer version is staged. Uses the existing addons-check-updates + a new app-restart IPC. New Aegis versions ship without a Theseus release. 32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to the right of the tab (mode: 'right') instead of a detached window. Matches stock Chrome. Users who prefer detached can drag out via the DevTools own toolbar. b71c925 - Search-engine favicons in Settings > Search now use Google's /s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough hosts (Brave, Bing, Yandex, etc.) that half the list was falling through to the emoji placeholder. Deployed. Verified LIVE 0.3.28.
451 lines
19 KiB
JavaScript
451 lines
19 KiB
JavaScript
// DigiByte (DGB) chain adapter — thin bridge over the @dgb-wallet/* packages
|
|
// vendored under lib/dgb/ from D:\Dev\SilentCode\Digibyte\packages\{core,psbt}.
|
|
// Address derivation, network params and PSBT construction come from the
|
|
// upstream design; Aegis provides the runtime shell (ElectrumX transport,
|
|
// gap-limit scanning, wallet-manager plumbing).
|
|
//
|
|
// Backend: DGB ElectrumX pool via Theseus's existing lib/electrum.js — same
|
|
// TCP-over-wss stack the BCH wallet uses, no separate protocol adapter.
|
|
// Optional Blockbook mode is on the roadmap; ElectrumX is the default
|
|
// because it matches Aegis's transport shape and needs no per-server keys.
|
|
//
|
|
// Only BIP84 (m/84'/20'/0'/0/x → dgb1q…) is exposed in this rev; the
|
|
// vendored core also supports BIP44 (D…) and BIP49 (S…) — plumb them by
|
|
// switching the purpose passed to accountNode(). Address recovery from any
|
|
// BIP39 tool at coin type 20 is guaranteed by bitcoinjs-lib's Network
|
|
// object, so a seed exported here can be restored on iancoleman.io/bip39
|
|
// or the SilentCode Digibyte web-wallet with matching addresses.
|
|
|
|
const NETWORK = "mainnet";
|
|
const DEFAULT_PURPOSE = 84;
|
|
const EXPLORER_TX = "https://digiexplorer.info/tx/";
|
|
const EXPLORER_ADDR = "https://digiexplorer.info/address/";
|
|
const DEFAULT_SERVERS = [
|
|
"wss://electrum1.cyberbits.eu:50022",
|
|
"wss://electrum3.cyberbits.eu:50022",
|
|
"wss://electrum1.digibyteblockexplorer.com:50022",
|
|
];
|
|
|
|
module.exports = function makeDgbAdapter({
|
|
dgbCore, // ESM namespace of @dgb-wallet/core (vendored)
|
|
dgbPsbt, // ESM namespace of @dgb-wallet/psbt (vendored)
|
|
bitcoinjs, // require("bitcoinjs-lib")
|
|
bip32Factory, // require("bip32").BIP32Factory
|
|
ecpairFactory, // require("ecpair").ECPairFactory
|
|
ecc, // require("@bitcoinerlab/secp256k1")
|
|
sha256, // @noble/hashes/sha2 (only used for scripthash reversal)
|
|
electrum,
|
|
}) {
|
|
if (!dgbCore || !dgbPsbt || !bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) {
|
|
throw new Error("chain-dgb: missing dep");
|
|
}
|
|
const { rootFromSeed, accountNode, addressNode, p2wpkhAddress, digibyte, DGB_COIN_TYPE } = dgbCore;
|
|
const { buildPsbt, signAllInputs, finalizeAndExtract, feeSats } = dgbPsbt;
|
|
const { payments, Psbt } = bitcoinjs;
|
|
const bip32 = bip32Factory(ecc);
|
|
const ECPair = ecpairFactory(ecc);
|
|
|
|
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
// Electrum scripthash: sha256(scriptPubKey), byte-reversed, hex.
|
|
function scripthashOf(scriptBuf) {
|
|
const h = sha256(scriptBuf);
|
|
const rev = Buffer.from(h).reverse();
|
|
return rev.toString("hex");
|
|
}
|
|
function scriptPubKeyBuf(pubkeyBuf) {
|
|
return payments.p2wpkh({ pubkey: pubkeyBuf, network: digibyte }).output;
|
|
}
|
|
|
|
// ---- keys --------------------------------------------------------------
|
|
// The HD node is the sole owner of the private key material; every derived
|
|
// entry keeps a reference so PSBT.signInput(index, node) can sign each
|
|
// input under its own key.
|
|
class WalletKeys {
|
|
constructor(root32, accountPath) {
|
|
const purpose = parsePurposeFromPath(accountPath) || DEFAULT_PURPOSE;
|
|
this._purpose = purpose;
|
|
// bip32.fromSeed uses bitcoinjs-lib's Network object — pass DGB's so
|
|
// extended keys serialize with the right BIP32 magic (0x0488B21E).
|
|
this._root = bip32.fromSeed(Buffer.from(root32), digibyte);
|
|
this._account = this._root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/0'`);
|
|
this._accountPath = `m/${purpose}'/${DGB_COIN_TYPE}'/0'`;
|
|
this._branch = [this._account.derive(0), this._account.derive(1)];
|
|
this._cache = new Map();
|
|
}
|
|
get xpub() { return this._account.neutered().toBase58(); }
|
|
get xprv() { return this._account.toBase58(); }
|
|
get accountPath() { return this._accountPath; }
|
|
entry(branch, index) {
|
|
const k = branch + "/" + index;
|
|
let e = this._cache.get(k);
|
|
if (!e) {
|
|
const node = this._branch[branch].derive(index);
|
|
const pubkey = Buffer.from(node.publicKey);
|
|
const address = p2wpkhAddress(node, digibyte);
|
|
const script = Buffer.from(scriptPubKeyBuf(pubkey));
|
|
e = {
|
|
branch, index, path: this._accountPath + "/" + branch + "/" + index,
|
|
publicKey: pubkey, script, scriptHex: script.toString("hex"),
|
|
scripthash: scripthashOf(script),
|
|
address,
|
|
_node: node,
|
|
};
|
|
this._cache.set(k, e);
|
|
}
|
|
return e;
|
|
}
|
|
signerFor(entry) {
|
|
// bitcoinjs-lib's PSBT accepts anything with .publicKey + .sign(hash).
|
|
// BIP32Interface fits, but ECPair.fromPrivateKey gives a plain signer
|
|
// that matches what the DGB web-wallet uses — pick that for parity.
|
|
return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: digibyte });
|
|
}
|
|
wipe() {
|
|
// BIP32Interface holds Buffers; drop references so GC picks them up.
|
|
for (const e of this._cache.values()) e._node = null;
|
|
this._cache.clear();
|
|
this._branch = null;
|
|
this._account = null;
|
|
this._root = null;
|
|
}
|
|
}
|
|
function parsePurposeFromPath(p) {
|
|
const m = /^m\/(\d+)'\/\d+'\/\d+'$/.exec(String(p || ""));
|
|
return m ? Number(m[1]) : null;
|
|
}
|
|
|
|
// ---- vsize model (fee estimation ahead of PSBT.getFee) -----------------
|
|
const OVERHEAD_VB = 10.5;
|
|
const P2WPKH_INPUT_VB = 68;
|
|
const P2WPKH_OUTPUT_VB = 31;
|
|
const feeVb = (nIn, nOut, feePerVb) => Math.ceil((OVERHEAD_VB + nIn * P2WPKH_INPUT_VB + nOut * P2WPKH_OUTPUT_VB) * feePerVb);
|
|
|
|
function scopedStorage(storage, keyPrefix) {
|
|
const k = (key) => keyPrefix + key;
|
|
return {
|
|
get: (key, fallback = null) => storage.get(k(key), fallback),
|
|
set: (key, value) => storage.set(k(key), value),
|
|
};
|
|
}
|
|
|
|
// ---- wallet ------------------------------------------------------------
|
|
class DgbWallet {
|
|
constructor(root32, {
|
|
walletId, storage, log = () => {}, onChange = () => {}, servers,
|
|
accountPath,
|
|
} = {}) {
|
|
if (!walletId) throw new Error("chain-dgb: walletId required");
|
|
this.walletId = walletId;
|
|
this.chain = "dgb";
|
|
this.network = NETWORK;
|
|
this.log = log;
|
|
this.onChange = onChange;
|
|
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
|
this._servers = Array.isArray(servers) && servers.length ? servers : DEFAULT_SERVERS.slice();
|
|
this._keys = new WalletKeys(root32, accountPath);
|
|
this._root = new Uint8Array(root32);
|
|
this._client = new electrum.Client(this._servers);
|
|
this._client.onServer = () => this._emit();
|
|
this._state = {
|
|
used: new Set(),
|
|
watched: new Map(),
|
|
height: 0,
|
|
balance: { confirmed: 0, unconfirmed: 0 },
|
|
utxos: [],
|
|
history: [],
|
|
receiveIndex: 0,
|
|
scanning: false,
|
|
error: null,
|
|
};
|
|
this._refreshTimer = null;
|
|
this._subscribedHeaders = false;
|
|
this._client.onNotify = (method, params) => {
|
|
if (method === "blockchain.headers.subscribe") {
|
|
const h = params && params[0] && params[0].height;
|
|
if (h) { this._state.height = h; this._scheduleRefresh(1500); }
|
|
} else if (method === "blockchain.scripthash.subscribe") {
|
|
this._scheduleRefresh(800);
|
|
}
|
|
};
|
|
}
|
|
|
|
_emit() { try { this.onChange(); } catch {} }
|
|
|
|
// ---- discovery (gap-limit) -----------------------------------------
|
|
async _historyOf(entry) {
|
|
const h = await this._client.call("blockchain.scripthash.get_history", [entry.scripthash]);
|
|
return Array.isArray(h) ? h : [];
|
|
}
|
|
async _scan() {
|
|
const cursor = Number(this.storage.get("receiveCursor", 0)) || 0;
|
|
const GAP = 20;
|
|
for (const branch of [0, 1]) {
|
|
let gap = 0, i = 0;
|
|
const minIndex = branch === 0 ? cursor + 1 : 0;
|
|
while (gap < GAP || i < minIndex + GAP) {
|
|
const batch = [];
|
|
for (let k = 0; k < 10; k++) batch.push(this._keys.entry(branch, i + k));
|
|
const results = await Promise.all(batch.map((e) => this._historyOf(e)));
|
|
for (let k = 0; k < batch.length; k++) {
|
|
const e = batch[k];
|
|
this._state.watched.set(e.scripthash, e);
|
|
if (results[k].length) { this._state.used.add(branch + "/" + e.index); gap = 0; } else gap++;
|
|
i++;
|
|
if (gap >= GAP && i >= minIndex + GAP) break;
|
|
}
|
|
}
|
|
}
|
|
let r = cursor;
|
|
while (this._state.used.has("0/" + r)) r++;
|
|
this._state.receiveIndex = r;
|
|
this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r));
|
|
}
|
|
async _subscribeAll() {
|
|
if (!this._subscribedHeaders) {
|
|
this._subscribedHeaders = true;
|
|
const tip = await this._client.subscribe("blockchain.headers.subscribe", []);
|
|
if (tip && tip.height) this._state.height = tip.height;
|
|
}
|
|
await Promise.all([...this._state.watched.values()].map((e) =>
|
|
this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {})));
|
|
}
|
|
async _loadUtxos() {
|
|
const lists = await Promise.all([...this._state.watched.values()].map(async (e) => {
|
|
const u = await this._client.call("blockchain.scripthash.listunspent", [e.scripthash]);
|
|
return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e }));
|
|
}));
|
|
this._state.utxos = lists.flat();
|
|
let confirmed = 0, unconfirmed = 0;
|
|
for (const u of this._state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
|
|
this._state.balance = { confirmed, unconfirmed };
|
|
}
|
|
async _loadHistory() {
|
|
// Best-effort tx-history summary: pull the transactions listed against
|
|
// any used scripthash, sum ours-vs-not to get a delta per tx. Fine on
|
|
// the DGB electrum pool (get_transaction verbose is supported).
|
|
const entries = [...this._state.watched.values()].filter((e) => this._state.used.has(e.branch + "/" + e.index));
|
|
const merged = new Map();
|
|
const lists = await Promise.all(entries.map((e) => this._historyOf(e)));
|
|
for (const list of lists) for (const h of list) {
|
|
const prev = merged.get(h.tx_hash);
|
|
if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height });
|
|
}
|
|
const ordered = [...merged.values()].sort((a, b) => {
|
|
const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity;
|
|
return hb - ha;
|
|
}).slice(0, 25);
|
|
const ours = new Set([...this._state.watched.values()].map((e) => e.scriptHex));
|
|
const out = [];
|
|
for (const h of ordered) {
|
|
let received = 0, spent = 0;
|
|
try {
|
|
const t = await this._client.call("blockchain.transaction.get", [h.txid, true]);
|
|
for (const o of t.vout || []) {
|
|
const hex = o.scriptPubKey && o.scriptPubKey.hex;
|
|
if (hex && ours.has(hex)) received += Math.round(Number(o.value || 0) * 1e8);
|
|
}
|
|
for (const i of t.vin || []) {
|
|
if (!i.txid) continue;
|
|
try {
|
|
const p = await this._client.call("blockchain.transaction.get", [i.txid, true]);
|
|
const po = p.vout && p.vout[i.vout];
|
|
const hex = po && po.scriptPubKey && po.scriptPubKey.hex;
|
|
if (hex && ours.has(hex)) spent += Math.round(Number(po.value || 0) * 1e8);
|
|
} catch {}
|
|
}
|
|
out.push({
|
|
txid: h.txid, height: h.height, confirmations: t.confirmations || 0,
|
|
time: t.blocktime || t.time || 0,
|
|
delta: received - spent, fee: null, to: null,
|
|
status: (t.confirmations || 0) > 0 ? "confirmed" : "pending",
|
|
kind: "transfer",
|
|
});
|
|
} catch {
|
|
out.push({ txid: h.txid, height: h.height, confirmations: 0, time: 0, delta: 0, fee: null, to: null, status: "pending", kind: "transfer" });
|
|
}
|
|
}
|
|
this._state.history = out;
|
|
}
|
|
async refresh(full = false) {
|
|
if (this._state.scanning) return;
|
|
this._state.scanning = true; this._state.error = null; this._emit();
|
|
try {
|
|
if (full || !this._state.watched.size) await this._scan();
|
|
else {
|
|
let r = Number(this.storage.get("receiveCursor", 0)) || 0;
|
|
while (this._state.used.has("0/" + r)) r++;
|
|
this._state.receiveIndex = r;
|
|
this._state.watched.set(this._keys.entry(0, r).scripthash, this._keys.entry(0, r));
|
|
}
|
|
await this._loadUtxos();
|
|
await this._loadHistory();
|
|
await this._subscribeAll();
|
|
for (const u of this._state.utxos) this._state.used.add(u.entry.branch + "/" + u.entry.index);
|
|
let r = Number(this.storage.get("receiveCursor", 0)) || 0;
|
|
while (this._state.used.has("0/" + r)) r++;
|
|
this._state.receiveIndex = r;
|
|
} catch (e) {
|
|
this._state.error = e?.message || String(e);
|
|
this.log("refresh failed:", this._state.error);
|
|
} finally {
|
|
this._state.scanning = false;
|
|
this._emit();
|
|
}
|
|
}
|
|
_scheduleRefresh(ms = 800) {
|
|
clearTimeout(this._refreshTimer);
|
|
this._refreshTimer = setTimeout(() => this.refresh(false), ms);
|
|
}
|
|
|
|
current() { return this._keys.entry(0, this._state.receiveIndex); }
|
|
nextAddress() {
|
|
let r = this._state.receiveIndex + 1;
|
|
while (this._state.used.has("0/" + r)) r++;
|
|
this.storage.set("receiveCursor", r);
|
|
this._state.receiveIndex = r;
|
|
const e = this._keys.entry(0, r);
|
|
this._state.watched.set(e.scripthash, e);
|
|
this._client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {});
|
|
this._emit();
|
|
return this.current();
|
|
}
|
|
_changeEntry() {
|
|
let i = 0;
|
|
while (this._state.used.has("1/" + i)) i++;
|
|
return this._keys.entry(1, i);
|
|
}
|
|
|
|
setServers(list) {
|
|
this._servers = Array.isArray(list) && list.length ? list : DEFAULT_SERVERS.slice();
|
|
this._client.setServers(this._servers);
|
|
}
|
|
|
|
// ---- plan + sign (via @dgb-wallet/psbt) -----------------------------
|
|
plan({ to, amount, feeRate = 20, sendMax = false }) {
|
|
const rate = Math.min(500, Math.max(1, Number(feeRate) || 20));
|
|
const dest = String(to || "");
|
|
// The `payments` decoder will reject anything that isn't a valid
|
|
// DGB address; catch and re-raise as a sane error.
|
|
try { payments.address({ address: dest, network: digibyte }); }
|
|
catch {
|
|
// bitcoinjs-lib's address decoder is `address.toOutputScript`, not
|
|
// payments.address; use it for validation.
|
|
try { bitcoinjs.address.toOutputScript(dest, digibyte); }
|
|
catch (e) { throw new Error(`bad DGB address: ${e?.message || dest}`); }
|
|
}
|
|
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
|
const change = this._changeEntry();
|
|
if (sendMax) {
|
|
const chosen = spendable;
|
|
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
|
const fee = feeVb(chosen.length, 1, rate);
|
|
if (sum <= fee) throw new Error("balance does not cover the fee");
|
|
return {
|
|
_chosen: chosen, _rate: rate, _to: dest, _sendMax: true, _change: change,
|
|
recipients: [{ to: dest, value: sum - fee }],
|
|
fee, feeRate: rate, change: 0,
|
|
total: sum,
|
|
};
|
|
}
|
|
const value = Math.round(Number(amount) || 0);
|
|
if (!(value > 0)) throw new Error("amount must be > 0");
|
|
let sum = 0; const chosen = [];
|
|
for (const u of spendable) {
|
|
chosen.push(u); sum += u.value;
|
|
const withChange = feeVb(chosen.length, 2, rate);
|
|
if (sum >= value + withChange) {
|
|
const changeVal = sum - value - withChange;
|
|
const fee = changeVal > 546 ? withChange : sum - value;
|
|
return {
|
|
_chosen: chosen, _rate: rate, _to: dest, _value: value,
|
|
_change: change, _changeVal: changeVal > 546 ? changeVal : 0,
|
|
recipients: [{ to: dest, value }],
|
|
fee, feeRate: rate,
|
|
change: changeVal > 546 ? changeVal : 0,
|
|
total: value + fee,
|
|
};
|
|
}
|
|
}
|
|
throw new Error("insufficient funds");
|
|
}
|
|
|
|
async signAndBroadcast(plan) {
|
|
const psbtInputs = plan._chosen.map((u) => ({
|
|
txid: u.txid,
|
|
vout: u.vout,
|
|
witness: { scriptHex: u.entry.scriptHex, value: u.value },
|
|
}));
|
|
const psbtOutputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }];
|
|
if (!plan._sendMax && plan._changeVal > 0) {
|
|
psbtOutputs.push({ address: plan._change.address, value: plan._changeVal });
|
|
}
|
|
const psbt = buildPsbt({ inputs: psbtInputs, outputs: psbtOutputs }, digibyte);
|
|
// Sign per-input with the exact key that funded that UTXO. signAllInputs
|
|
// would work when all inputs share a key, but each derived address
|
|
// has its own key, so we go per-input.
|
|
for (let i = 0; i < plan._chosen.length; i++) {
|
|
const signer = this._keys.signerFor(plan._chosen[i].entry);
|
|
psbt.signInput(i, signer);
|
|
}
|
|
const { hex, txid } = finalizeAndExtract(psbt);
|
|
const broadcast = await this._client.call("blockchain.transaction.broadcast", [hex]);
|
|
if (typeof broadcast !== "string" || broadcast.length !== 64) {
|
|
throw new Error("broadcast rejected: " + JSON.stringify(broadcast));
|
|
}
|
|
this.log("broadcast", txid);
|
|
this._scheduleRefresh(1200);
|
|
return { txid, hex, fee: plan.fee };
|
|
}
|
|
|
|
// BIP-137-style: 65-byte recoverable signature over sha256d(magic || msg).
|
|
signMessage(message) {
|
|
const enc = new TextEncoder();
|
|
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); };
|
|
const MAGIC = "DigiByte Signed Message:\n";
|
|
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
|
|
const digest = sha256(sha256(payload));
|
|
const entry = this.current();
|
|
const signer = this._keys.signerFor(entry);
|
|
// ECPair's `signSchnorr` and `sign` don't emit recoverable sigs; fall
|
|
// back to node's ecc.signRecoverable through @bitcoinerlab/secp256k1
|
|
// (which the vendored @dgb-wallet/core already loaded).
|
|
const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey);
|
|
const out = Buffer.alloc(65);
|
|
out[0] = 27 + sig.recoveryId + 4; // +4 = compressed pubkey
|
|
Buffer.from(sig.signature).copy(out, 1);
|
|
return { address: entry.address, signature: out.toString("base64") };
|
|
}
|
|
|
|
recovery() {
|
|
return { accountPath: this._keys.accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv };
|
|
}
|
|
|
|
snapshot() {
|
|
const cur = this.current();
|
|
return {
|
|
chain: "dgb", network: NETWORK, ticker: "DGB", decimals: 8,
|
|
address: cur.address, addressIndex: this._state.receiveIndex,
|
|
addressPath: cur.path,
|
|
balance: this._state.balance,
|
|
height: this._state.height,
|
|
history: this._state.history,
|
|
scanning: this._state.scanning,
|
|
error: this._state.error,
|
|
server: this._client.url || null,
|
|
servers: this._servers,
|
|
accountPath: this._keys.accountPath,
|
|
xpub: this._keys.xpub,
|
|
explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
|
|
};
|
|
}
|
|
|
|
dispose() {
|
|
clearTimeout(this._refreshTimer);
|
|
try { this._keys.wipe(); } catch {}
|
|
try { this._client.disconnect(); } catch {}
|
|
if (this._root) this._root.fill(0);
|
|
}
|
|
}
|
|
|
|
return { DgbWallet, EXPLORER_TX, EXPLORER_ADDR };
|
|
};
|