Ship Theseus 0.3.28 5d15508b (Aegis update card + DevTools in tab sidebar + real favicons)
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.
This commit is contained in:
commit
c7c7e428c7
50 changed files with 8109 additions and 0 deletions
15
addon.json
Normal file
15
addon.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"id": "bchwallet",
|
||||
"name": "Aegis Wallet",
|
||||
"version": "0.3.1",
|
||||
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "🛡",
|
||||
"main": "index.js",
|
||||
"capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"],
|
||||
"absorbs": ["siawallet"],
|
||||
"page-inject": {
|
||||
"preload": "wallet-inject.js",
|
||||
"origins": ["https://*/*"]
|
||||
}
|
||||
}
|
||||
9
electrum-servers.json
Normal file
9
electrum-servers.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[
|
||||
"wss://bch.imaginary.cash:50004",
|
||||
"wss://electrum.imaginary.cash:50004",
|
||||
"wss://fulcrum.jettscythe.xyz:50004",
|
||||
"wss://bch.loping.net:50004",
|
||||
"wss://bch.soul-dev.com:50004",
|
||||
"wss://cashnode.bch.ninja:50004",
|
||||
"wss://blackie.c3-soft.com:50004"
|
||||
]
|
||||
83
lib/base58check.js
Normal file
83
lib/base58check.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Base58Check encode/decode for Tron addresses (0x41 || H160 || sha256d[:4]).
|
||||
// Bitcoin-style base58 alphabet; the caller supplies the raw 21-byte payload
|
||||
// (version byte first) so this module knows nothing about Tron itself.
|
||||
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
const INDEX = new Int8Array(128).fill(-1);
|
||||
for (let i = 0; i < ALPHABET.length; i++) INDEX[ALPHABET.charCodeAt(i)] = i;
|
||||
|
||||
module.exports = function makeBase58Check({ sha256 }) {
|
||||
function encodeBase58(bytes) {
|
||||
let zeros = 0;
|
||||
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
||||
// Convert base-256 → base-58 by repeated division.
|
||||
const b58 = new Uint8Array(Math.ceil(bytes.length * 138 / 100 + 1));
|
||||
let length = 0;
|
||||
for (let i = zeros; i < bytes.length; i++) {
|
||||
let carry = bytes[i];
|
||||
let j = 0;
|
||||
for (let k = b58.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
|
||||
carry += (b58[k] << 8) >>> 0;
|
||||
b58[k] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
length = j;
|
||||
}
|
||||
// Skip leading zero-bytes in the base58 buffer, then prepend '1' per leading zero-byte in input.
|
||||
let it = b58.length - length;
|
||||
while (it < b58.length && b58[it] === 0) it++;
|
||||
let out = "";
|
||||
for (let i = 0; i < zeros; i++) out += ALPHABET[0];
|
||||
for (; it < b58.length; it++) out += ALPHABET[b58[it]];
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeBase58(str) {
|
||||
if (typeof str !== "string" || str.length === 0) throw new Error("base58: empty input");
|
||||
let zeros = 0;
|
||||
while (zeros < str.length && str[zeros] === ALPHABET[0]) zeros++;
|
||||
const out = new Uint8Array(Math.ceil(str.length * 733 / 1000 + 1));
|
||||
let length = 0;
|
||||
for (let i = zeros; i < str.length; i++) {
|
||||
const c = str.charCodeAt(i);
|
||||
const val = c < 128 ? INDEX[c] : -1;
|
||||
if (val < 0) throw new Error("base58: bad character " + JSON.stringify(str[i]));
|
||||
let carry = val;
|
||||
let j = 0;
|
||||
for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
|
||||
carry += 58 * out[k];
|
||||
out[k] = carry & 0xff;
|
||||
carry >>>= 8;
|
||||
}
|
||||
length = j;
|
||||
}
|
||||
let it = out.length - length;
|
||||
while (it < out.length && out[it] === 0) it++;
|
||||
const total = zeros + (out.length - it);
|
||||
const decoded = new Uint8Array(total);
|
||||
for (let i = 0; i < zeros; i++) decoded[i] = 0;
|
||||
let p = zeros;
|
||||
while (it < out.length) decoded[p++] = out[it++];
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function encodeCheck(payload) {
|
||||
const bytes = payload instanceof Uint8Array ? payload : Uint8Array.from(payload);
|
||||
const check = sha256(sha256(bytes)).slice(0, 4);
|
||||
const full = new Uint8Array(bytes.length + 4);
|
||||
full.set(bytes, 0);
|
||||
full.set(check, bytes.length);
|
||||
return encodeBase58(full);
|
||||
}
|
||||
|
||||
function decodeCheck(str) {
|
||||
const full = decodeBase58(str);
|
||||
if (full.length < 5) throw new Error("base58check: too short");
|
||||
const payload = full.slice(0, full.length - 4);
|
||||
const check = full.slice(full.length - 4);
|
||||
const want = sha256(sha256(payload)).slice(0, 4);
|
||||
for (let i = 0; i < 4; i++) if (check[i] !== want[i]) throw new Error("base58check: bad checksum");
|
||||
return payload;
|
||||
}
|
||||
|
||||
return { encodeBase58, decodeBase58, encodeCheck, decodeCheck };
|
||||
};
|
||||
102
lib/cashaddr.js
Normal file
102
lib/cashaddr.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// CashAddr (BCH address format) encode/decode + legacy Base58Check decode, so
|
||||
// the send form accepts whatever the user pastes but the UI only ever shows
|
||||
// cashaddr. Pure JS, no deps — the polymod is tiny.
|
||||
const CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
|
||||
const CHARSET_REV = Object.fromEntries([...CHARSET].map((c, i) => [c, i]));
|
||||
|
||||
function polymod(values) {
|
||||
let c = 1n;
|
||||
for (const d of values) {
|
||||
const c0 = c >> 35n;
|
||||
c = ((c & 0x07ffffffffn) << 5n) ^ BigInt(d);
|
||||
if (c0 & 0x01n) c ^= 0x98f2bc8e61n;
|
||||
if (c0 & 0x02n) c ^= 0x79b76d99e2n;
|
||||
if (c0 & 0x04n) c ^= 0xf33e5fb3c4n;
|
||||
if (c0 & 0x08n) c ^= 0xae2eabe2a8n;
|
||||
if (c0 & 0x10n) c ^= 0x1e4f43e470n;
|
||||
}
|
||||
return c ^ 1n;
|
||||
}
|
||||
const prefixExpand = (prefix) => [...prefix].map((c) => c.charCodeAt(0) & 0x1f).concat([0]);
|
||||
|
||||
function convertBits(data, from, to, pad) {
|
||||
let acc = 0, bits = 0; const out = [];
|
||||
const maxv = (1 << to) - 1;
|
||||
for (const v of data) {
|
||||
acc = (acc << from) | v; bits += from;
|
||||
while (bits >= to) { bits -= to; out.push((acc >> bits) & maxv); }
|
||||
}
|
||||
if (pad) { if (bits > 0) out.push((acc << (to - bits)) & maxv); }
|
||||
else if (bits >= from || ((acc << (to - bits)) & maxv)) throw new Error("cashaddr: bad padding");
|
||||
return out;
|
||||
}
|
||||
|
||||
// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit).
|
||||
function encode(prefix, type, hash) {
|
||||
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
|
||||
const versionByte = (type << 3) | 0; // size bits 000 = 160
|
||||
const payload = convertBits([versionByte, ...hash], 8, 5, true);
|
||||
const mod = polymod([...prefixExpand(prefix), ...payload, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const checksum = [];
|
||||
for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn));
|
||||
return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join("");
|
||||
}
|
||||
|
||||
// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
|
||||
function decode(address, defaultPrefix = "bitcoincash") {
|
||||
const raw = String(address).trim();
|
||||
if (raw !== raw.toLowerCase() && raw !== raw.toUpperCase()) throw new Error("cashaddr: mixed case");
|
||||
const s = raw.toLowerCase();
|
||||
const i = s.lastIndexOf(":");
|
||||
const prefix = i >= 0 ? s.slice(0, i) : defaultPrefix;
|
||||
const payloadStr = i >= 0 ? s.slice(i + 1) : s;
|
||||
if (!/^[a-z0-9]+$/.test(prefix) || payloadStr.length < 8) throw new Error("cashaddr: malformed");
|
||||
const values = [...payloadStr].map((c) => {
|
||||
if (!(c in CHARSET_REV)) throw new Error("cashaddr: bad character");
|
||||
return CHARSET_REV[c];
|
||||
});
|
||||
if (polymod([...prefixExpand(prefix), ...values]) !== 0n) throw new Error("cashaddr: bad checksum");
|
||||
const data = convertBits(values.slice(0, -8), 5, 8, false);
|
||||
const versionByte = data[0];
|
||||
const type = (versionByte >> 3) & 0x0f;
|
||||
const size = [20, 24, 28, 32, 40, 48, 56, 64][versionByte & 0x07];
|
||||
const hash = Uint8Array.from(data.slice(1));
|
||||
if (hash.length !== size) throw new Error("cashaddr: hash size mismatch");
|
||||
return { prefix, type, hash };
|
||||
}
|
||||
|
||||
// Legacy Base58Check (1... / 3... on mainnet). sha256 is injected so this
|
||||
// file stays dependency-free.
|
||||
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
function decodeLegacy(address, sha256) {
|
||||
const s = String(address).trim();
|
||||
let n = 0n;
|
||||
for (const c of s) {
|
||||
const v = B58.indexOf(c);
|
||||
if (v < 0) throw new Error("base58: bad character");
|
||||
n = n * 58n + BigInt(v);
|
||||
}
|
||||
const bytes = [];
|
||||
while (n > 0n) { bytes.unshift(Number(n & 0xffn)); n >>= 8n; }
|
||||
for (const c of s) { if (c !== "1") break; bytes.unshift(0); }
|
||||
if (bytes.length !== 25) throw new Error("base58: wrong length");
|
||||
const body = Uint8Array.from(bytes.slice(0, 21));
|
||||
const check = sha256(sha256(body)).slice(0, 4);
|
||||
for (let k = 0; k < 4; k++) if (check[k] !== bytes[21 + k]) throw new Error("base58: bad checksum");
|
||||
const type = body[0] === 0x00 ? 0 : body[0] === 0x05 ? 1 : null;
|
||||
if (type == null) throw new Error("base58: not a mainnet address");
|
||||
return { prefix: "bitcoincash", type, hash: body.slice(1) };
|
||||
}
|
||||
|
||||
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other
|
||||
// prefixes so a chipnet address can never be paid on mainnet by accident.
|
||||
function parseAny(input, sha256, prefix = "bitcoincash") {
|
||||
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
|
||||
if (!s) throw new Error("empty address");
|
||||
const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix);
|
||||
if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`);
|
||||
if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type");
|
||||
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) };
|
||||
}
|
||||
|
||||
module.exports = { encode, decode, decodeLegacy, parseAny };
|
||||
156
lib/chain-bch.js
Normal file
156
lib/chain-bch.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
// BCH chain adapter — wraps the existing keys.js / wallet.js / tx.js /
|
||||
// electrum.js / cashaddr.js code with the common adapter shape that the
|
||||
// multi-wallet manager talks to. Every BCH wallet is one BIP32 account
|
||||
// derived from its own 32-byte root (from api.vault.derive).
|
||||
//
|
||||
// Deps are handed in so index.js loads @noble/* once and shares them across
|
||||
// every wallet, rather than each adapter dynamic-importing on its own.
|
||||
|
||||
const BCH_NETWORKS = {
|
||||
mainnet: {
|
||||
id: "mainnet",
|
||||
label: "Mainnet",
|
||||
prefix: "bitcoincash",
|
||||
defaultAccountPath: "m/44'/145'/0'",
|
||||
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
|
||||
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
|
||||
defaultServers: [
|
||||
"wss://bch.imaginary.cash:50004",
|
||||
"wss://cashnode.bch.ninja:50004",
|
||||
"wss://electroncash.dk:50004",
|
||||
"wss://fulcrum.jettscythe.xyz:50004",
|
||||
],
|
||||
faucet: null,
|
||||
},
|
||||
chipnet: {
|
||||
id: "chipnet",
|
||||
label: "Chipnet testnet",
|
||||
prefix: "bchtest",
|
||||
// BIP44 testnet coin type is 1 across every chain; the account is still 0.
|
||||
defaultAccountPath: "m/44'/1'/0'",
|
||||
explorerTx: "https://chipnet.imaginary.cash/tx/",
|
||||
explorerAddr: "https://chipnet.imaginary.cash/address/",
|
||||
defaultServers: [
|
||||
"wss://chipnet.imaginary.cash:50004",
|
||||
"wss://chipnet.bch.ninja:50004",
|
||||
],
|
||||
faucet: "https://tbch.googol.cash/",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = function makeBchAdapter({
|
||||
HDKey, secp256k1, sha256, ripemd160, cashaddr,
|
||||
keysLib, tx, electrum, WebSocket,
|
||||
}) {
|
||||
|
||||
// storage is the FULL api.storage. keyPrefix scopes every read/write under
|
||||
// "wallets/<walletId>/…" so multiple BCH wallets don't stomp each other.
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
class BchWallet {
|
||||
constructor(root32, {
|
||||
walletId, storage, log = () => {}, onChange = () => {}, servers,
|
||||
network = "mainnet", accountPath,
|
||||
} = {}) {
|
||||
if (!walletId) throw new Error("chain-bch: walletId required");
|
||||
const net = BCH_NETWORKS[network];
|
||||
if (!net) throw new Error(`chain-bch: unknown network ${network}`);
|
||||
this.walletId = walletId;
|
||||
this.chain = "bch";
|
||||
this.network = net.id;
|
||||
this._net = net;
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
||||
// Servers: caller-provided override → user-set custom list (handled by
|
||||
// index.js already, this is a fallback path) → the network's built-in
|
||||
// defaults so a wallet always has somewhere to connect.
|
||||
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
|
||||
const wantPath = accountPath || net.defaultAccountPath;
|
||||
this._accountPath = /^m(\/\d+'?)+$/.test(wantPath) ? wantPath : net.defaultAccountPath;
|
||||
this._client = new electrum.Client(this._servers);
|
||||
this._client.onServer = () => this._emit();
|
||||
this._keys = new keysLib.WalletKeys(root32, this._accountPath, net.prefix);
|
||||
this._root = new Uint8Array(root32);
|
||||
const walletFactory = require("./wallet.js");
|
||||
this._wallet = walletFactory({
|
||||
client: this._client, keys: this._keys, tx, cashaddr, sha256,
|
||||
storage: this.storage,
|
||||
log: (...a) => this.log(...a),
|
||||
onChange: () => this._emit(),
|
||||
});
|
||||
}
|
||||
|
||||
setServers(list) {
|
||||
this._servers = Array.isArray(list) && list.length ? list : this._net.defaultServers.slice();
|
||||
this._client.setServers(this._servers);
|
||||
}
|
||||
|
||||
_emit() { try { this.onChange(); } catch {} }
|
||||
|
||||
snapshot() {
|
||||
const w = this._wallet.snapshot();
|
||||
return {
|
||||
chain: "bch",
|
||||
network: this._net.id,
|
||||
ticker: "BCH",
|
||||
decimals: 8,
|
||||
address: w.address,
|
||||
addressIndex: w.addressIndex,
|
||||
addressPath: w.addressPath,
|
||||
balance: w.balance,
|
||||
height: w.height,
|
||||
history: w.history,
|
||||
scanning: w.scanning,
|
||||
error: w.error,
|
||||
server: this._client.url || null,
|
||||
servers: this._servers,
|
||||
accountPath: this._accountPath,
|
||||
xpub: this._keys.xpub,
|
||||
explorerTx: this._net.explorerTx,
|
||||
explorerAddr: this._net.explorerAddr,
|
||||
faucet: this._net.faucet,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(full) { return this._wallet.refresh(!!full); }
|
||||
nextAddress() { return this._wallet.nextUnusedAddress(); }
|
||||
current() { return this._wallet.current(); }
|
||||
plan(spec) {
|
||||
const targets = Array.isArray(spec.outputs) && spec.outputs.length
|
||||
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
|
||||
: [{ to: spec.to, value: spec.amount ?? spec.value }];
|
||||
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
|
||||
}
|
||||
async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
|
||||
// 65-byte BIP-137 recoverable signature — the format Electron Cash and
|
||||
// most BCH tooling verify against.
|
||||
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 = "Bitcoin Signed Message:\n";
|
||||
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
|
||||
const digest = sha256(sha256(payload));
|
||||
const entry = this.current();
|
||||
const sig = this._keys.signRecoverable(entry, digest);
|
||||
return { address: entry.address, signature: Buffer.from(sig).toString("base64") };
|
||||
}
|
||||
recovery() {
|
||||
return { accountPath: this._accountPath, xpub: this._keys.xpub, xprv: this._keys.xprv };
|
||||
}
|
||||
dispose() {
|
||||
try { this._wallet.dispose(); } catch {}
|
||||
try { this._keys.wipe(); } catch {}
|
||||
try { this._client.disconnect(); } catch {}
|
||||
if (this._root) this._root.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { BchWallet, BCH_NETWORKS };
|
||||
};
|
||||
531
lib/chain-btc.js
Normal file
531
lib/chain-btc.js
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
// Bitcoin (BTC) chain adapter — mainnet + testnet3. BIP84 native SegWit,
|
||||
// bitcoinjs-lib for the tx/PSBT primitives, Aegis's own ElectrumX transport
|
||||
// for the network side. Very close in shape to chain-dgb.js; the two could
|
||||
// share a "bip84-electrum" helper later, but for now a distinct file keeps
|
||||
// the chain-specific tuning (electrum pool, network object) visible.
|
||||
//
|
||||
// Address family: BIP84 only in this rev — bc1q… (mainnet) / tb1q… (testnet).
|
||||
// BIP44 (1…) and BIP49 (3…) are trivially reachable by editing accountPath
|
||||
// to m/44'/0'/0' or m/49'/0'/0' respectively; the PSBT layer already
|
||||
// supports the resulting scripts because bitcoinjs-lib does. An explicit
|
||||
// address-family picker like DGB's is a follow-up.
|
||||
|
||||
const NETWORKS = {
|
||||
mainnet: {
|
||||
id: "mainnet", label: "Mainnet",
|
||||
hrp: "bc", coinType: 0,
|
||||
defaultAccountPath: "m/84'/0'/0'",
|
||||
explorerTx: "https://mempool.space/tx/",
|
||||
explorerAddr: "https://mempool.space/address/",
|
||||
defaultServers: [
|
||||
"wss://electrum.blockstream.info:50004",
|
||||
"wss://bitcoin.lu.ke:50004",
|
||||
"wss://fulcrum.grey.pw:50004",
|
||||
],
|
||||
faucet: null,
|
||||
},
|
||||
testnet: {
|
||||
id: "testnet", label: "Testnet3",
|
||||
hrp: "tb", coinType: 1,
|
||||
defaultAccountPath: "m/84'/1'/0'",
|
||||
explorerTx: "https://mempool.space/testnet/tx/",
|
||||
explorerAddr: "https://mempool.space/testnet/address/",
|
||||
defaultServers: [
|
||||
"wss://testnet.aranguren.org:51004",
|
||||
"wss://blockstream.info:993",
|
||||
],
|
||||
faucet: "https://coinfaucet.eu/en/btc-testnet/",
|
||||
},
|
||||
signet: {
|
||||
// Signet (BIP-325) shares testnet's address format and SLIP-44 coin
|
||||
// type (1), so bitcoinjs-lib's `networks.testnet` handles address
|
||||
// derivation unchanged. The chain itself is a separate, permissioned
|
||||
// testnet with its own genesis + signer-signed blocks; from a wallet's
|
||||
// point of view, the only differences are the electrum pool serving
|
||||
// it and the explorer URL for tx lookups.
|
||||
id: "signet", label: "Signet",
|
||||
hrp: "tb", coinType: 1,
|
||||
defaultAccountPath: "m/84'/1'/0'",
|
||||
explorerTx: "https://mempool.space/signet/tx/",
|
||||
explorerAddr: "https://mempool.space/signet/address/",
|
||||
defaultServers: [
|
||||
"wss://signet.aranguren.org:51102",
|
||||
"wss://signet-electrumx.wakiyamap.dev:50003",
|
||||
],
|
||||
faucet: "https://signetfaucet.com/",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = function makeBtcAdapter({
|
||||
bitcoinjs, bip32Factory, ecpairFactory, ecc, sha256, electrum,
|
||||
}) {
|
||||
if (!bitcoinjs || !bip32Factory || !ecpairFactory || !ecc || !electrum) {
|
||||
throw new Error("chain-btc: missing dep");
|
||||
}
|
||||
const { payments, Psbt, networks: bjsNetworks } = bitcoinjs;
|
||||
const bip32 = bip32Factory(ecc);
|
||||
const ECPair = ecpairFactory(ecc);
|
||||
// Taproot (p2tr) address derivation needs bitcoinjs-lib's schnorr backend
|
||||
// wired to a curve implementation — @bitcoinerlab/secp256k1 provides both
|
||||
// ECDSA and schnorr, so initEccLib once at load makes p2tr resolve.
|
||||
try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {}
|
||||
|
||||
// Map our network id → bitcoinjs-lib Network object. Signet shares
|
||||
// testnet's address prefixes + magic (BIP-325 defines only new consensus
|
||||
// rules; the p2p / address layer stays testnet-compatible).
|
||||
function bjsNetworkFor(id) {
|
||||
if (id === "mainnet") return bjsNetworks.bitcoin;
|
||||
if (id === "testnet" || id === "signet") return bjsNetworks.testnet;
|
||||
throw new Error("chain-btc: unknown network " + id);
|
||||
}
|
||||
|
||||
const toHex = (b) => Buffer.from(b).toString("hex");
|
||||
const scripthashOf = (scriptBuf) => Buffer.from(sha256(scriptBuf)).reverse().toString("hex");
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// Address-family shape from the derivation-path purpose. Every field the
|
||||
// PSBT layer might need for signing an input funded by this family is
|
||||
// captured here so signAndBroadcast has one code path per family.
|
||||
function paymentFor(purpose, node, network) {
|
||||
const pubkey = Buffer.from(node.publicKey);
|
||||
if (purpose === 44) {
|
||||
// Legacy P2PKH. Signing needs the full previous transaction
|
||||
// (nonWitnessUtxo) — one extra electrum call per input at send time.
|
||||
const p = payments.p2pkh({ pubkey, network });
|
||||
return { family: "bip44", address: p.address, output: Buffer.from(p.output), send: "p2pkh", needsPrevTx: true };
|
||||
}
|
||||
if (purpose === 49) {
|
||||
// P2SH-wrapped SegWit. PSBT needs the redeem script (the inner p2wpkh
|
||||
// output) alongside the witnessUtxo.
|
||||
const redeem = payments.p2wpkh({ pubkey, network });
|
||||
const p = payments.p2sh({ redeem, network });
|
||||
return { family: "bip49", address: p.address, output: Buffer.from(p.output), redeem: Buffer.from(redeem.output), send: "p2sh-p2wpkh" };
|
||||
}
|
||||
if (purpose === 86) {
|
||||
// BIP86 Taproot key-path. Signing goes through a tap-tweaked ECPair
|
||||
// (see signerFor + signAndBroadcast); the internal 32-byte x-only
|
||||
// pubkey is captured here so the PSBT input can carry it.
|
||||
const internalPubkey = Buffer.from(pubkey.subarray(1, 33));
|
||||
const p = payments.p2tr({ internalPubkey, network });
|
||||
return { family: "bip86", address: p.address, output: Buffer.from(p.output), internalPubkey, send: "p2tr" };
|
||||
}
|
||||
// Default: BIP84 native SegWit.
|
||||
const p = payments.p2wpkh({ pubkey, network });
|
||||
return { family: "bip84", address: p.address, output: Buffer.from(p.output), send: "p2wpkh" };
|
||||
}
|
||||
function purposeOfPath(accountPath) {
|
||||
const m = /^m\/(\d+)'\//.exec(String(accountPath || ""));
|
||||
return m ? Number(m[1]) : 84;
|
||||
}
|
||||
|
||||
class WalletKeys {
|
||||
constructor(root32, accountPath, bjsNetwork) {
|
||||
this._accountPath = /^m(\/\d+'?)+$/.test(accountPath) ? accountPath : "m/84'/0'/0'";
|
||||
this._purpose = purposeOfPath(this._accountPath);
|
||||
this._network = bjsNetwork;
|
||||
this._root = bip32.fromSeed(Buffer.from(root32), bjsNetwork);
|
||||
this._account = this._root.derivePath(this._accountPath);
|
||||
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; }
|
||||
get purpose() { return this._purpose; }
|
||||
entry(branch, index) {
|
||||
const k = branch + "/" + index;
|
||||
let e = this._cache.get(k);
|
||||
if (!e) {
|
||||
const node = this._branch[branch].derive(index);
|
||||
const pay = paymentFor(this._purpose, node, this._network);
|
||||
e = {
|
||||
branch, index, path: this._accountPath + "/" + branch + "/" + index,
|
||||
publicKey: Buffer.from(node.publicKey),
|
||||
family: pay.family, sendKind: pay.send,
|
||||
script: pay.output, scriptHex: pay.output.toString("hex"),
|
||||
scripthash: scripthashOf(pay.output),
|
||||
address: pay.address,
|
||||
redeemScript: pay.redeem || null,
|
||||
tapInternalKey: pay.internalPubkey || null,
|
||||
_node: node,
|
||||
};
|
||||
this._cache.set(k, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
signerFor(entry) {
|
||||
return ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._network });
|
||||
}
|
||||
wipe() {
|
||||
for (const e of this._cache.values()) e._node = null;
|
||||
this._cache.clear();
|
||||
this._branch = null;
|
||||
this._account = null;
|
||||
this._root = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fee vsize model per family. Values are rounded vsize contributions from
|
||||
// standard tx-size tables; the estimator is pessimistic enough to cover a
|
||||
// real broadcast without underpaying.
|
||||
const OVERHEAD_VB = 10.5;
|
||||
const OUTPUT_VB = 31; // P2WPKH / P2SH / P2PKH outputs are all ~31 vB give or take
|
||||
const INPUT_VB = {
|
||||
p2pkh: 148, // (32+4)+1+107+4 legacy input
|
||||
"p2sh-p2wpkh": 91, // 40 base + ~205/4 witness
|
||||
p2wpkh: 68, // 41 base + 108/4 witness
|
||||
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);
|
||||
|
||||
class BtcWallet {
|
||||
constructor(root32, networkId, {
|
||||
walletId, storage, log = () => {}, onChange = () => {}, servers,
|
||||
accountPath,
|
||||
} = {}) {
|
||||
if (!walletId) throw new Error("chain-btc: walletId required");
|
||||
const net = NETWORKS[networkId];
|
||||
if (!net) throw new Error("chain-btc: unknown network " + networkId);
|
||||
this.walletId = walletId;
|
||||
this.chain = "btc";
|
||||
this.network = net.id;
|
||||
this._net = net;
|
||||
this._bjsNet = bjsNetworkFor(net.id);
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
||||
this._servers = Array.isArray(servers) && servers.length ? servers : net.defaultServers.slice();
|
||||
const wantPath = accountPath || net.defaultAccountPath;
|
||||
this._keys = new WalletKeys(root32, wantPath, this._bjsNet);
|
||||
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 {} }
|
||||
|
||||
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() {
|
||||
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 : this._net.defaultServers.slice();
|
||||
this._client.setServers(this._servers);
|
||||
}
|
||||
|
||||
plan({ to, amount, feeRate = 5, sendMax = false }) {
|
||||
const rate = Math.min(500, Math.max(1, Number(feeRate) || 5));
|
||||
const dest = String(to || "");
|
||||
try { bitcoinjs.address.toOutputScript(dest, this._bjsNet); }
|
||||
catch (e) { throw new Error(`bad Bitcoin address: ${e?.message || dest}`); }
|
||||
const cur = this.current();
|
||||
if (!cur.sendKind) throw new Error(`no sender for ${cur.family} — registry bug`);
|
||||
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
||||
const change = this._changeEntry();
|
||||
const kind = cur.sendKind;
|
||||
if (sendMax) {
|
||||
const chosen = spendable;
|
||||
const sum = chosen.reduce((a, u) => a + u.value, 0);
|
||||
const fee = feeVb(kind, 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, _kind: kind,
|
||||
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(kind, 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, _kind: kind,
|
||||
recipients: [{ to: dest, value }],
|
||||
fee, feeRate: rate,
|
||||
change: changeVal > 546 ? changeVal : 0,
|
||||
total: value + fee,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error("insufficient funds");
|
||||
}
|
||||
|
||||
async signAndBroadcast(plan) {
|
||||
// BIP44 inputs need the whole previous transaction (nonWitnessUtxo)
|
||||
// so PSBT can compute a legacy sighash; fetch each one in parallel
|
||||
// before assembling the PSBT.
|
||||
const needsPrev = plan._chosen.filter((u) => u.entry.family === "bip44");
|
||||
const prevHex = new Map();
|
||||
if (needsPrev.length) {
|
||||
const results = await Promise.all(needsPrev.map((u) =>
|
||||
this._client.call("blockchain.transaction.get", [u.txid, false])
|
||||
));
|
||||
needsPrev.forEach((u, i) => prevHex.set(u.txid, String(results[i])));
|
||||
}
|
||||
const psbt = new Psbt({ network: this._bjsNet });
|
||||
for (const u of plan._chosen) {
|
||||
const inp = { hash: u.txid, index: u.vout };
|
||||
const fam = u.entry.family;
|
||||
if (fam === "bip44") {
|
||||
inp.nonWitnessUtxo = Buffer.from(prevHex.get(u.txid), "hex");
|
||||
} else {
|
||||
inp.witnessUtxo = { script: u.entry.script, value: u.value };
|
||||
if (fam === "bip49" && u.entry.redeemScript) inp.redeemScript = u.entry.redeemScript;
|
||||
if (fam === "bip86" && u.entry.tapInternalKey) inp.tapInternalKey = u.entry.tapInternalKey;
|
||||
}
|
||||
psbt.addInput(inp);
|
||||
}
|
||||
const outputs = [{ address: plan._to, value: plan._sendMax ? plan.recipients[0].value : plan._value }];
|
||||
if (!plan._sendMax && plan._changeVal > 0) {
|
||||
outputs.push({ address: plan._change.address, value: plan._changeVal });
|
||||
}
|
||||
for (const o of outputs) psbt.addOutput(o);
|
||||
for (let i = 0; i < plan._chosen.length; i++) {
|
||||
const entry = plan._chosen[i].entry;
|
||||
// Taproot key-path: bitcoinjs-lib matches the signer's publicKey
|
||||
// against the tweaked output key, so the signer has to be the
|
||||
// internal ECPair tweaked with sha256("TapTweak" || internalPubkey).
|
||||
// ECPair.tweak() from the ecpair package does exactly that (its
|
||||
// internal state becomes the tap-tweaked keypair) and its
|
||||
// signSchnorr is what PSBT calls for a key-path spend.
|
||||
if (entry.family === "bip86") {
|
||||
const raw = ECPair.fromPrivateKey(Buffer.from(entry._node.privateKey), { network: this._bjsNet });
|
||||
const tweak = bitcoinjs.crypto.taggedHash("TapTweak", entry.tapInternalKey);
|
||||
const tweaked = raw.tweak(tweak);
|
||||
psbt.signInput(i, tweaked);
|
||||
} else {
|
||||
psbt.signInput(i, this._keys.signerFor(entry));
|
||||
}
|
||||
}
|
||||
psbt.finalizeAllInputs();
|
||||
const tx = psbt.extractTransaction();
|
||||
const hex = tx.toHex();
|
||||
const txid = await this._client.call("blockchain.transaction.broadcast", [hex]);
|
||||
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
|
||||
this.log("broadcast", txid);
|
||||
this._scheduleRefresh(1200);
|
||||
return { txid, hex, fee: plan.fee };
|
||||
}
|
||||
|
||||
// BIP-137 recoverable over sha256d("Bitcoin Signed Message:\n" || 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 = "Bitcoin 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);
|
||||
const sig = ecc.signRecoverable(Buffer.from(digest), signer.privateKey);
|
||||
const out = Buffer.alloc(65);
|
||||
out[0] = 27 + sig.recoveryId + 4; // +4 = compressed
|
||||
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: "btc", network: this._net.id, ticker: "BTC", 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: this._net.explorerTx,
|
||||
explorerAddr: this._net.explorerAddr,
|
||||
faucet: this._net.faucet,
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
clearTimeout(this._refreshTimer);
|
||||
try { this._keys.wipe(); } catch {}
|
||||
try { this._client.disconnect(); } catch {}
|
||||
if (this._root) this._root.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
return { BtcWallet, NETWORKS };
|
||||
};
|
||||
451
lib/chain-dgb.js
Normal file
451
lib/chain-dgb.js
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
// 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 };
|
||||
};
|
||||
361
lib/chain-eth.js
Normal file
361
lib/chain-eth.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// Ethereum (EVM) chain adapter — mainnet + Sepolia testnet. One address per
|
||||
// wallet, the same "TronLink shape" as chain-tron.js: BIP44 derivation,
|
||||
// secp256k1 → keccak256 address, JSON-RPC backend, EIP-1559 send.
|
||||
//
|
||||
// Kept intentionally minimal:
|
||||
// - Native ETH only. ERC-20 token support is a follow-up: it needs a token
|
||||
// registry + eth_call for `balanceOf(address)` per token + a dedicated
|
||||
// Send flow that builds an ERC-20 `transfer(to, value)` calldata.
|
||||
// - No transaction history: without an indexer (Etherscan V2 / Alchemy)
|
||||
// the JSON-RPC alone can't answer "which txs touched this address".
|
||||
// The panel shows an empty history with a link to Etherscan.
|
||||
// - EIP-1559 only (type 0x02). Legacy type 0x00 works too but isn't
|
||||
// needed for mainnet or Sepolia in 2026.
|
||||
|
||||
const NETWORKS = {
|
||||
mainnet: {
|
||||
id: "mainnet", label: "Mainnet", chainId: 1,
|
||||
// Cloudflare's public Ethereum gateway — no key required, rate-limited
|
||||
// but adequate for a per-user wallet. User can override in settings.
|
||||
defaultRpc: "https://cloudflare-eth.com",
|
||||
explorerTx: "https://etherscan.io/tx/",
|
||||
explorerAddr: "https://etherscan.io/address/",
|
||||
faucet: null,
|
||||
},
|
||||
sepolia: {
|
||||
id: "sepolia", label: "Sepolia testnet", chainId: 11155111,
|
||||
defaultRpc: "https://ethereum-sepolia-rpc.publicnode.com",
|
||||
explorerTx: "https://sepolia.etherscan.io/tx/",
|
||||
explorerAddr: "https://sepolia.etherscan.io/address/",
|
||||
faucet: "https://sepoliafaucet.com/",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = function makeEthAdapter({ HDKey, secp256k1, keccak_256 }) {
|
||||
if (!HDKey || !secp256k1 || !keccak_256) throw new Error("chain-eth: missing dep");
|
||||
|
||||
const toHex = (b) => Buffer.from(b).toString("hex");
|
||||
const fromHex = (h) => Uint8Array.from(Buffer.from(String(h).replace(/^0x/i, ""), "hex"));
|
||||
const stripHex = (h) => String(h).replace(/^0x/i, "");
|
||||
const hexToBig = (h) => BigInt("0x" + (stripHex(h) || "0"));
|
||||
const bigToHex = (n) => "0x" + BigInt(n).toString(16);
|
||||
const zeroBig = 0n;
|
||||
|
||||
// ---- addresses -------------------------------------------------------
|
||||
// EIP-55 mixed-case checksum: lowercase hex, then flip case per keccak256
|
||||
// of the lowercase hex string (a-f digits get uppercased where the keccak
|
||||
// nibble is >= 8). Never needed for wire format (RPCs accept lowercase),
|
||||
// but it's what wallets show, so we return it that way.
|
||||
function eip55(addressLowerHex) {
|
||||
const lower = stripHex(addressLowerHex).toLowerCase();
|
||||
const hash = toHex(keccak_256(Buffer.from(lower, "utf8")));
|
||||
let out = "0x";
|
||||
for (let i = 0; i < lower.length; i++) {
|
||||
const c = lower[i];
|
||||
out += /[0-9]/.test(c) ? c : (parseInt(hash[i], 16) >= 8 ? c.toUpperCase() : c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function addressFromPubkey(uncompressed) {
|
||||
const inner = uncompressed.slice(1);
|
||||
const h = keccak_256(inner);
|
||||
const h20 = h.slice(h.length - 20);
|
||||
return eip55(toHex(h20));
|
||||
}
|
||||
function decodeAddress(str) {
|
||||
const s = String(str || "").trim();
|
||||
const hex = stripHex(s);
|
||||
if (!/^[0-9a-fA-F]{40}$/.test(hex)) throw new Error("bad Ethereum address");
|
||||
// Reject checksum mismatches on mixed-case inputs (all-lower and all-upper
|
||||
// pass unconditionally — that's the EIP-55 rule).
|
||||
const lower = hex.toLowerCase(), upper = hex.toUpperCase();
|
||||
if (hex !== lower && hex !== upper) {
|
||||
const want = stripHex(eip55(lower));
|
||||
if (hex !== want) throw new Error("EIP-55 checksum failed");
|
||||
}
|
||||
return "0x" + lower;
|
||||
}
|
||||
|
||||
// ---- RLP encode ------------------------------------------------------
|
||||
// Minimal encoder — enough for EIP-1559 tx encoding. Follows the RLP spec
|
||||
// (single byte < 0x80 → self; short string ≤ 55 → 0x80 + len + bytes;
|
||||
// long string → 0x80 + 55 + lenOfLen + lenBytes + bytes; lists similarly
|
||||
// with 0xc0/0xf7).
|
||||
function rlpEncodeBytes(bytes) {
|
||||
const b = Uint8Array.from(bytes);
|
||||
if (b.length === 1 && b[0] < 0x80) return b;
|
||||
if (b.length <= 55) return concat(Uint8Array.from([0x80 + b.length]), b);
|
||||
const lenBytes = encodeIntBE(b.length);
|
||||
return concat(Uint8Array.from([0xb7 + lenBytes.length]), lenBytes, b);
|
||||
}
|
||||
function rlpEncodeList(items) {
|
||||
const encoded = items.map(rlpEncode);
|
||||
const body = concat(...encoded);
|
||||
if (body.length <= 55) return concat(Uint8Array.from([0xc0 + body.length]), body);
|
||||
const lenBytes = encodeIntBE(body.length);
|
||||
return concat(Uint8Array.from([0xf7 + lenBytes.length]), lenBytes, body);
|
||||
}
|
||||
function rlpEncode(item) {
|
||||
if (item instanceof Uint8Array) return rlpEncodeBytes(item);
|
||||
if (Array.isArray(item)) return rlpEncodeList(item);
|
||||
if (typeof item === "bigint") return rlpEncodeBytes(bigToBytes(item));
|
||||
if (typeof item === "number") return rlpEncodeBytes(bigToBytes(BigInt(item)));
|
||||
if (typeof item === "string") return rlpEncodeBytes(item.startsWith("0x") ? fromHex(item) : Buffer.from(item, "utf8"));
|
||||
throw new Error("rlp: unsupported item type " + typeof item);
|
||||
}
|
||||
function bigToBytes(v) {
|
||||
if (v < 0n) throw new Error("negative bigint");
|
||||
if (v === 0n) return new Uint8Array(0);
|
||||
let hex = v.toString(16);
|
||||
if (hex.length % 2) hex = "0" + hex;
|
||||
return fromHex(hex);
|
||||
}
|
||||
function encodeIntBE(n) {
|
||||
let hex = n.toString(16);
|
||||
if (hex.length % 2) hex = "0" + hex;
|
||||
return fromHex(hex);
|
||||
}
|
||||
function concat(...ps) {
|
||||
const n = ps.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let k = 0;
|
||||
for (const p of ps) { out.set(p, k); k += p.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- signing ---------------------------------------------------------
|
||||
// EIP-1559 signed tx: 0x02 || RLP([chainId, nonce, maxPriorityFeePerGas,
|
||||
// maxFeePerGas, gasLimit, to, value, data, accessList,
|
||||
// yParity, r, s])
|
||||
// hash-to-sign: keccak256(0x02 || RLP([...same-without-sig-fields]))
|
||||
function signTxEip1559(unsignedFields, privKey) {
|
||||
const unsignedRlp = rlpEncodeList(unsignedFields);
|
||||
const preimage = concat(Uint8Array.from([0x02]), unsignedRlp);
|
||||
const hash = keccak_256(preimage);
|
||||
const sig = secp256k1.sign(hash, privKey, { prehash: false, lowS: true, format: "recovered" });
|
||||
// noble returns [recid || r(32) || s(32)]; EIP-1559 uses yParity as
|
||||
// 0 or 1 (recid directly, no +27 shift).
|
||||
const yParity = sig[0];
|
||||
const r = sig.subarray(1, 33);
|
||||
const s = sig.subarray(33, 65);
|
||||
const signedFields = [...unsignedFields, yParity, stripLeadingZeros(r), stripLeadingZeros(s)];
|
||||
const signedRlp = rlpEncodeList(signedFields);
|
||||
return "0x" + toHex(concat(Uint8Array.from([0x02]), signedRlp));
|
||||
}
|
||||
function stripLeadingZeros(bytes) {
|
||||
let i = 0;
|
||||
while (i < bytes.length - 1 && bytes[i] === 0) i++;
|
||||
return bytes.subarray(i);
|
||||
}
|
||||
|
||||
// ---- JSON-RPC client -------------------------------------------------
|
||||
function makeClient(rpcUrl) {
|
||||
let seq = 1;
|
||||
async function call(method, params = []) {
|
||||
const r = await fetch(rpcUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`);
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
}
|
||||
return { url: rpcUrl, call };
|
||||
}
|
||||
|
||||
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 EthWallet {
|
||||
constructor(root32, networkId, {
|
||||
walletId, storage, log = () => {}, onChange = () => {}, rpcUrl,
|
||||
customNetwork, // { id, label, chainId, defaultRpc, explorerTx, explorerAddr, ticker } for EIP-3085 chains
|
||||
} = {}) {
|
||||
if (!walletId) throw new Error("chain-eth: walletId required");
|
||||
const net = customNetwork || NETWORKS[networkId];
|
||||
if (!net) throw new Error(`chain-eth: unknown network ${networkId}`);
|
||||
this.walletId = walletId;
|
||||
this.chain = "eth";
|
||||
this.network = net.id;
|
||||
this._net = net;
|
||||
this._ticker = net.ticker || "ETH";
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
||||
const master = HDKey.fromMasterSeed(root32);
|
||||
// BIP44 for Ethereum: m/44'/60'/0'/0/0 is the canonical first address.
|
||||
const node = master.derive("m/44'/60'/0'/0/0");
|
||||
this._priv = node.privateKey;
|
||||
this._pubUncompressed = secp256k1.getPublicKey(this._priv, false);
|
||||
this.address = addressFromPubkey(this._pubUncompressed);
|
||||
this._root = new Uint8Array(root32);
|
||||
this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc);
|
||||
this._state = {
|
||||
balance: { confirmed: "0", unconfirmed: "0" },
|
||||
history: [],
|
||||
height: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
};
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
setRpcUrl(url) {
|
||||
const v = String(url || "").trim() || this._net.defaultRpc;
|
||||
this._client = makeClient(v);
|
||||
this._emit();
|
||||
}
|
||||
_emit() { try { this.onChange(); } catch {} }
|
||||
|
||||
// Wei is 10^18 native units; the panel formats via decimals=18. The
|
||||
// ticker follows the chain's nativeCurrency (ETH on mainnet/Sepolia,
|
||||
// MATIC on Polygon, etc.) so the send-approval overlay reads correctly.
|
||||
snapshot() {
|
||||
return {
|
||||
chain: "eth", network: this._net.id, ticker: this._ticker, decimals: 18,
|
||||
address: this.address, addressIndex: 0,
|
||||
addressPath: "m/44'/60'/0'/0/0",
|
||||
balance: this._state.balance,
|
||||
height: this._state.height,
|
||||
history: this._state.history,
|
||||
scanning: this._state.scanning,
|
||||
error: this._state.error,
|
||||
server: this._client.url,
|
||||
rpcUrl: this._client.url,
|
||||
explorerTx: this._net.explorerTx,
|
||||
explorerAddr: this._net.explorerAddr,
|
||||
faucet: this._net.faucet,
|
||||
chainId: this._net.chainId,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this._state.scanning) return;
|
||||
this._state.scanning = true; this._state.error = null; this._emit();
|
||||
try {
|
||||
const [bal, block] = await Promise.all([
|
||||
this._client.call("eth_getBalance", [this.address, "latest"]),
|
||||
this._client.call("eth_blockNumber", []),
|
||||
]);
|
||||
this._state.balance = { confirmed: hexToBig(bal).toString(), unconfirmed: "0" };
|
||||
this._state.height = Number(hexToBig(block));
|
||||
} catch (e) {
|
||||
this._state.error = e?.message || String(e);
|
||||
this.log("refresh failed:", this._state.error);
|
||||
} finally {
|
||||
this._state.scanning = false;
|
||||
this._emit();
|
||||
}
|
||||
}
|
||||
schedulePoll(ms = 20_000) {
|
||||
clearTimeout(this._pollTimer);
|
||||
this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
|
||||
}
|
||||
|
||||
async plan({ to, amount, sendMax }) {
|
||||
const dest = decodeAddress(to);
|
||||
const from = this.address.toLowerCase();
|
||||
const [nonceHex, priorityHex, gasPriceHex, gasLimitHex] = await Promise.all([
|
||||
this._client.call("eth_getTransactionCount", [from, "pending"]),
|
||||
this._client.call("eth_maxPriorityFeePerGas", []).catch(() => "0x59682f00"), // fallback: 1.5 gwei
|
||||
this._client.call("eth_gasPrice", []),
|
||||
Promise.resolve("0x5208"), // 21000 for a plain ETH transfer
|
||||
]);
|
||||
const nonce = Number(hexToBig(nonceHex));
|
||||
const maxPriorityFeePerGas = hexToBig(priorityHex);
|
||||
// maxFeePerGas heuristic: 2 * base fee + priority tip. base fee ~=
|
||||
// gasPrice - priority tip on EIP-1559 chains; we approximate with the
|
||||
// reported gasPrice as an upper bound plus the priority.
|
||||
const baseGuess = hexToBig(gasPriceHex);
|
||||
const maxFeePerGas = baseGuess * 2n + maxPriorityFeePerGas;
|
||||
const gasLimit = hexToBig(gasLimitHex);
|
||||
const fee = gasLimit * maxFeePerGas;
|
||||
const bal = BigInt(this._state.balance.confirmed || "0");
|
||||
let value;
|
||||
if (sendMax) {
|
||||
if (bal <= fee) throw new Error("balance does not cover the gas fee");
|
||||
value = bal - fee;
|
||||
} else {
|
||||
value = BigInt(Math.round(Number(amount) || 0)); // wei
|
||||
if (value <= 0n) throw new Error("amount must be > 0 wei");
|
||||
if (value + fee > bal) throw new Error("insufficient funds");
|
||||
}
|
||||
return {
|
||||
_draft: {
|
||||
chainId: this._net.chainId, nonce, maxPriorityFeePerGas, maxFeePerGas,
|
||||
gasLimit, to: dest, value, data: "0x", accessList: [],
|
||||
},
|
||||
recipients: [{ to: dest, value: value.toString() }],
|
||||
fee: fee.toString(),
|
||||
feeRate: maxFeePerGas.toString(),
|
||||
inputs: [],
|
||||
change: "0",
|
||||
total: (value + fee).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async signAndBroadcast(plan) {
|
||||
const d = plan && plan._draft;
|
||||
if (!d) throw new Error("bad plan");
|
||||
const unsignedFields = [
|
||||
d.chainId, d.nonce, d.maxPriorityFeePerGas, d.maxFeePerGas, d.gasLimit,
|
||||
fromHex(d.to.slice(2)), d.value, fromHex(""), [],
|
||||
];
|
||||
const rawTxHex = signTxEip1559(unsignedFields, 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));
|
||||
this.log("broadcast", txid);
|
||||
setTimeout(() => this.refresh(), 3000);
|
||||
return { txid };
|
||||
}
|
||||
|
||||
// EIP-712: sign a pre-computed typed-data digest with r||s||v (v = 27+recid).
|
||||
signTypedDataDigest(digest32) {
|
||||
const sig = secp256k1.sign(digest32, this._priv, { prehash: false, lowS: true, format: "recovered" });
|
||||
const out = new Uint8Array(65);
|
||||
out.set(sig.subarray(1), 0);
|
||||
out[64] = sig[0] + 27;
|
||||
return { address: this.address, signature: "0x" + toHex(out) };
|
||||
}
|
||||
// Ethereum personal_sign: keccak256("\x19Ethereum Signed Message:\n" + len + msg).
|
||||
signMessage(message) {
|
||||
const msg = String(message);
|
||||
const enc = new TextEncoder();
|
||||
const body = enc.encode(msg);
|
||||
const prefix = enc.encode("\x19Ethereum Signed Message:\n" + body.length);
|
||||
const buf = concat(prefix, body);
|
||||
const hash = keccak_256(buf);
|
||||
const sig = secp256k1.sign(hash, this._priv, { prehash: false, lowS: true, format: "recovered" });
|
||||
// personal_sign format: r || s || v where v = 27 + recid.
|
||||
const out = new Uint8Array(65);
|
||||
out.set(sig.subarray(1), 0);
|
||||
out[64] = sig[0] + 27;
|
||||
return { address: this.address, signature: "0x" + toHex(out) };
|
||||
}
|
||||
|
||||
recovery() {
|
||||
// Ethereum wallets typically expose the raw private key hex; we do too,
|
||||
// but only when the caller re-confirms in the approval overlay upstream.
|
||||
return {
|
||||
accountPath: "m/44'/60'/0'/0/0",
|
||||
xpub: "0x" + toHex(this._pubUncompressed),
|
||||
xprv: "0x" + toHex(this._priv),
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
clearTimeout(this._pollTimer);
|
||||
try { this._priv && this._priv.fill(0); } catch {}
|
||||
try { this._root && this._root.fill(0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { EthWallet, NETWORKS, addressFromPubkey, decodeAddress, eip55 };
|
||||
};
|
||||
182
lib/chain-sia.js
Normal file
182
lib/chain-sia.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// Sia (SC) chain adapter — v2 walletd-backed. Constructs a wallet from a
|
||||
// 32-byte root and a user-supplied walletd URL. Amounts are hastings
|
||||
// (1 SC = 10^24 hastings) and cross the panel boundary as decimal strings
|
||||
// so the panel never has to touch BigInt precision.
|
||||
//
|
||||
// Deps: sia.js / keys.js / wallet.js / walletd.js — copied from the
|
||||
// standalone siawallet addon on 2026-09-07 when Aegis absorbed it, so the
|
||||
// key derivation + tx layout are byte-identical to the older addon.
|
||||
// Existing on-chain funds carry over via the vault-derive absorb (aegis's
|
||||
// manifest lists "siawallet" under absorbs, so the legacy purpose
|
||||
// "siawallet/mainnet/0" resolves to the same seed inside Aegis).
|
||||
|
||||
const SC = 10n ** 24n;
|
||||
const EXPLORER_TX = "https://siascan.com/tx/";
|
||||
const EXPLORER_ADDR = "https://siascan.com/address/";
|
||||
|
||||
module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
|
||||
if (!ed25519 || !blake2b) throw new Error("chain-sia: missing dep");
|
||||
const sia = require("./sia/sia.js")({ ed25519, blake2b });
|
||||
const keysLib = require("./sia/keys.js")({ sia });
|
||||
const walletd = require("./sia/walletd.js")({ log: () => {} });
|
||||
const walletFactory = require("./sia/wallet.js");
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
class SiaWallet {
|
||||
constructor(root32, {
|
||||
walletId, storage, log = () => {}, onChange = () => {},
|
||||
walletdUrl = "",
|
||||
} = {}) {
|
||||
if (!walletId) throw new Error("chain-sia: walletId required");
|
||||
this.walletId = walletId;
|
||||
this.chain = "sc";
|
||||
this.network = "mainnet";
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
||||
this._root = new Uint8Array(root32);
|
||||
this._keys = new keysLib.WalletKeys(root32);
|
||||
this._walletdUrl = String(walletdUrl || "").trim();
|
||||
this._client = null;
|
||||
this._wallet = null;
|
||||
if (this._walletdUrl) this._build();
|
||||
}
|
||||
|
||||
_build() {
|
||||
if (this._wallet) { try { this._wallet.dispose(); } catch {} this._wallet = null; }
|
||||
if (this._client) this._client.setBase(this._walletdUrl);
|
||||
else this._client = new walletd.Client(this._walletdUrl);
|
||||
this._wallet = walletFactory({
|
||||
client: this._client, keys: this._keys, sia,
|
||||
storage: this.storage,
|
||||
log: (...a) => this.log(...a),
|
||||
onChange: () => { try { this.onChange(); } catch {} },
|
||||
});
|
||||
}
|
||||
|
||||
setWalletdUrl(url) {
|
||||
const v = String(url || "").trim();
|
||||
if (v === this._walletdUrl) return;
|
||||
this._walletdUrl = v;
|
||||
if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; }
|
||||
}
|
||||
|
||||
// The panel treats Sia amounts as decimal strings of hastings; the
|
||||
// display layer picks how many SC-precision digits to show.
|
||||
snapshot() {
|
||||
const w = this._wallet && this._wallet.snapshot();
|
||||
const base = {
|
||||
chain: "sc", network: "mainnet", ticker: "SC", decimals: 24,
|
||||
address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`,
|
||||
balance: { confirmed: "0", unconfirmed: "0" },
|
||||
height: 0, history: [], scanning: false, error: null,
|
||||
server: this._client ? this._client.displayUrl : null,
|
||||
walletdUrl: this._walletdUrl,
|
||||
needsWalletdUrl: !this._walletdUrl,
|
||||
explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
|
||||
};
|
||||
if (w) {
|
||||
base.address = w.address;
|
||||
base.addressIndex = w.addressIndex;
|
||||
// wallet.js exposes confirmed / immature; the panel's shared shape
|
||||
// is confirmed / unconfirmed, so map immature → unconfirmed for
|
||||
// visual parity with BCH and TRX (small semantic bend, but the
|
||||
// number is the "not yet spendable" one either way).
|
||||
base.balance = { confirmed: w.balance.confirmed, unconfirmed: w.balance.immature };
|
||||
base.height = w.height;
|
||||
base.history = (w.history || []).map((r) => ({
|
||||
txid: r.id, delta: r.delta, to: r.to, from: null,
|
||||
fee: null, time: r.time || 0,
|
||||
confirmations: r.confirmations || 0,
|
||||
status: r.confirmations > 0 ? "confirmed" : "pending",
|
||||
kind: r.type,
|
||||
}));
|
||||
base.scanning = w.scanning;
|
||||
base.error = w.error;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
async refresh(full) {
|
||||
if (!this._wallet) return;
|
||||
return this._wallet.refresh(!!full);
|
||||
}
|
||||
nextAddress() {
|
||||
if (!this._wallet) throw new Error("no walletd URL configured");
|
||||
return this._wallet.nextUnusedAddress();
|
||||
}
|
||||
current() {
|
||||
if (!this._wallet) throw new Error("no walletd URL configured");
|
||||
return this._wallet.current();
|
||||
}
|
||||
plan(spec) {
|
||||
if (!this._wallet) throw new Error("no walletd URL configured");
|
||||
const targets = Array.isArray(spec.outputs) && spec.outputs.length
|
||||
? spec.outputs.map((o) => ({ to: o.to, value: toHastings(o.amount ?? o.value) }))
|
||||
: [{ to: spec.to, value: toHastings(spec.amount ?? spec.value) }];
|
||||
const p = this._wallet.plan({ targets, feeMultiplier: spec.feeMultiplier || spec.feeRate, sendMax: !!spec.sendMax });
|
||||
// Present the plan in the common panel shape: BigInts as decimal
|
||||
// strings, plus `total = sum(recipients) + fee`.
|
||||
const sent = p.recipients.reduce((a, r) => a + BigInt(r.value), 0n);
|
||||
return {
|
||||
_sia: p, // internal handle so signAndBroadcast doesn't re-plan
|
||||
recipients: p.recipients,
|
||||
fee: p.fee.toString(),
|
||||
feeRate: p.feePerByte.toString(),
|
||||
inputs: p.tx.inputs,
|
||||
change: p.change.toString(),
|
||||
total: (sent + p.fee).toString(),
|
||||
};
|
||||
}
|
||||
async signAndBroadcast(plan) {
|
||||
if (!this._wallet) throw new Error("no walletd URL configured");
|
||||
const inner = plan && plan._sia;
|
||||
if (!inner) throw new Error("bad plan");
|
||||
return this._wallet.signAndBroadcast(inner);
|
||||
}
|
||||
// Sia signature = ed25519 over blake2b256 of the raw message. Not a
|
||||
// BIP-137 style thing — dapps that want it should treat this as an
|
||||
// opaque {publicKey, signature} pair verified via ed25519.
|
||||
signMessage(message) {
|
||||
const entry = this.current();
|
||||
const digest = sia.b256(new TextEncoder().encode(String(message)));
|
||||
const sig = this._keys.sign(entry, digest);
|
||||
return {
|
||||
address: entry.address,
|
||||
publicKey: "ed25519:" + sia.toHex(entry.pub),
|
||||
signature: sia.toHex(sig),
|
||||
};
|
||||
}
|
||||
recovery() {
|
||||
// Sia has no xpub/xprv notion here; the scheme is "seed + index".
|
||||
return {
|
||||
accountPath: `KeyFromSeed(seed, i)`,
|
||||
xpub: sia.toHex(this._keys.entry(0).pub), // just the first pub for reference
|
||||
xprv: this._keys.seedHex,
|
||||
};
|
||||
}
|
||||
startPolling() { if (this._wallet) this._wallet.startPolling(60_000); }
|
||||
dispose() {
|
||||
try { this._wallet && this._wallet.dispose(); } catch {}
|
||||
try { this._keys && this._keys.wipe(); } catch {}
|
||||
if (this._root) this._root.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Amounts arrive as strings (hastings) or as small numbers.
|
||||
function toHastings(v) {
|
||||
if (typeof v === "bigint") return v;
|
||||
const s = String(v ?? "0").trim();
|
||||
if (!/^\d+$/.test(s)) throw new Error("amount must be an integer number of hastings");
|
||||
return BigInt(s);
|
||||
}
|
||||
|
||||
return { SiaWallet, EXPLORER_TX, EXPLORER_ADDR };
|
||||
};
|
||||
473
lib/chain-sol.js
Normal file
473
lib/chain-sol.js
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
// Solana (SOL) chain adapter — mainnet-beta + devnet. Ed25519 keypair per
|
||||
// SLIP-0010 (all-hardened path), base58 address, native SOL transfers via
|
||||
// the system program. Sits directly on the JSON-RPC endpoint; no @solana
|
||||
// SDK dep so the addon stays lean.
|
||||
//
|
||||
// Derivation: m/44'/501'/0'/0' — Phantom's default path. Every segment is
|
||||
// hardened per SLIP-0010 (ed25519 forbids non-hardened derivation because
|
||||
// the point-add trick that BIP32 uses on secp256k1 doesn't exist for
|
||||
// Curve25519). Multi-index accounts under one wallet aren't exposed here;
|
||||
// each Aegis "sub-account" gets its own vault-derive purpose instead.
|
||||
//
|
||||
// Not implemented in this rev:
|
||||
// - SPL token balances / transfers (needs Associated Token Account math
|
||||
// and the SPL Token program's transfer instruction).
|
||||
// - Transaction history (getSignaturesForAddress + getTransaction is
|
||||
// doable but heavy for a first cut; the panel links to Solana Explorer
|
||||
// for now).
|
||||
|
||||
const NETWORKS = {
|
||||
mainnet: {
|
||||
id: "mainnet", label: "Mainnet",
|
||||
defaultRpc: "https://api.mainnet-beta.solana.com",
|
||||
explorerTx: "https://explorer.solana.com/tx/",
|
||||
explorerAddr: "https://explorer.solana.com/address/",
|
||||
explorerCluster: "",
|
||||
faucet: null,
|
||||
},
|
||||
devnet: {
|
||||
id: "devnet", label: "Devnet",
|
||||
defaultRpc: "https://api.devnet.solana.com",
|
||||
explorerTx: "https://explorer.solana.com/tx/",
|
||||
explorerAddr: "https://explorer.solana.com/address/",
|
||||
explorerCluster: "?cluster=devnet",
|
||||
faucet: "https://faucet.solana.com/",
|
||||
},
|
||||
};
|
||||
|
||||
// System program's address is 32 bytes of zeros; base58 is "1111...1111".
|
||||
const SYSTEM_PROGRAM = new Uint8Array(32);
|
||||
|
||||
module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) {
|
||||
if (!ed25519 || !base58 || !sha256) throw new Error("chain-sol: missing dep");
|
||||
const spl = require("./sol-spl.js")({ ed25519, sha256, base58 });
|
||||
|
||||
// ---- HMAC-SHA512 (for SLIP-0010) -------------------------------------
|
||||
// Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes
|
||||
// exports `hmac` in ./hmac. If unavailable, use Node's crypto — Electron
|
||||
// main is Node, so require("crypto") always works.
|
||||
const nodeCrypto = require("node:crypto");
|
||||
function hmacSha512(key, msg) {
|
||||
return new Uint8Array(nodeCrypto.createHmac("sha512", Buffer.from(key)).update(Buffer.from(msg)).digest());
|
||||
}
|
||||
|
||||
// ---- SLIP-0010 ed25519 derivation ------------------------------------
|
||||
const ED25519_MASTER_KEY = new TextEncoder().encode("ed25519 seed");
|
||||
function slip10Master(seed32) {
|
||||
const I = hmacSha512(ED25519_MASTER_KEY, seed32);
|
||||
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
||||
}
|
||||
function slip10Derive(parent, indexHardened) {
|
||||
// Data: 0x00 || parent.key || uint32BE(0x80000000 | index)
|
||||
const idx = 0x80000000 | (indexHardened & 0x7fffffff);
|
||||
const data = new Uint8Array(1 + 32 + 4);
|
||||
data[0] = 0x00;
|
||||
data.set(parent.key, 1);
|
||||
// Write index as big-endian u32; JS bitwise is signed so |0 masks correctly.
|
||||
data[33] = (idx >>> 24) & 0xff;
|
||||
data[34] = (idx >>> 16) & 0xff;
|
||||
data[35] = (idx >>> 8) & 0xff;
|
||||
data[36] = idx & 0xff;
|
||||
const I = hmacSha512(parent.chainCode, data);
|
||||
return { key: I.slice(0, 32), chainCode: I.slice(32) };
|
||||
}
|
||||
function derivePath(seed32, segments) {
|
||||
let node = slip10Master(seed32);
|
||||
for (const s of segments) node = slip10Derive(node, s);
|
||||
return node;
|
||||
}
|
||||
// "m/44'/501'/0'/0'" → [44, 501, 0, 0]. Every SLIP-0010 ed25519 segment
|
||||
// is hardened; the parser accepts either the standard "'" suffix or a
|
||||
// bare integer (both mean the same for this curve).
|
||||
function parseAllHardened(path) {
|
||||
const parts = String(path || "").trim().split("/").filter((p) => p && p !== "m");
|
||||
return parts.map((p) => {
|
||||
const m = /^(\d+)'?$/.exec(p);
|
||||
if (!m) throw new Error("bad SOL derivation path: " + path);
|
||||
return Number(m[1]);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Solana short-vec (compact-u16) ----------------------------------
|
||||
// Up to 3 bytes; 7 data bits per byte with continuation bit in position 7.
|
||||
function encodeCompactU16(n) {
|
||||
if (n < 0 || n > 0xffff) throw new Error("compact-u16 out of range");
|
||||
const out = [];
|
||||
let rem = n;
|
||||
while (true) {
|
||||
let byte = rem & 0x7f;
|
||||
rem >>= 7;
|
||||
if (rem === 0) { out.push(byte); break; }
|
||||
byte |= 0x80;
|
||||
out.push(byte);
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
const concat = (...ps) => {
|
||||
const n = ps.reduce((a, p) => a + p.length, 0);
|
||||
const o = new Uint8Array(n); let k = 0;
|
||||
for (const p of ps) { o.set(p, k); k += p.length; }
|
||||
return o;
|
||||
};
|
||||
const u64le = (n) => {
|
||||
let v = BigInt(n); const o = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return o;
|
||||
};
|
||||
|
||||
// ---- transaction assembly --------------------------------------------
|
||||
// For a native SOL transfer between two addresses:
|
||||
// accounts (writable-signed | readonly-signed | writable-unsigned | readonly-unsigned):
|
||||
// [ from (WS), to (WU), systemProgram (RU) ]
|
||||
// header = [1 required-sig, 0 readonly-signed, 1 readonly-unsigned]
|
||||
// instructions = [{ programIdIndex: 2, accounts: [0, 1], data: u32(2) || u64(lamports) }]
|
||||
function buildSolTransferMessage({ fromPub, toPub, lamports, recentBlockhash }) {
|
||||
// account keys must be de-duplicated in the message; distinct here.
|
||||
const keys = [fromPub, toPub, SYSTEM_PROGRAM];
|
||||
const header = Uint8Array.from([1, 0, 1]);
|
||||
const keysSection = concat(
|
||||
encodeCompactU16(keys.length),
|
||||
...keys.map((k) => Uint8Array.from(k)),
|
||||
);
|
||||
// Instruction data: [2 (u32 LE = system Transfer discriminator), lamports (u64 LE)]
|
||||
const instrData = concat(Uint8Array.from([2, 0, 0, 0]), u64le(lamports));
|
||||
const instr = concat(
|
||||
Uint8Array.from([2]), // programIdIndex
|
||||
encodeCompactU16(2), // account key count
|
||||
Uint8Array.from([0, 1]), // account indexes (from, to)
|
||||
encodeCompactU16(instrData.length), // data length
|
||||
instrData,
|
||||
);
|
||||
const instrSection = concat(encodeCompactU16(1), instr);
|
||||
return concat(header, keysSection, recentBlockhash, instrSection);
|
||||
}
|
||||
|
||||
// ---- JSON-RPC --------------------------------------------------------
|
||||
function makeClient(rpcUrl) {
|
||||
let seq = 1;
|
||||
async function call(method, params = []) {
|
||||
const r = await fetch(rpcUrl, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", id: seq++, method, params }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`${method}: HTTP ${r.status}`);
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`);
|
||||
return j.result;
|
||||
}
|
||||
return { url: rpcUrl, call };
|
||||
}
|
||||
|
||||
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 SolWallet {
|
||||
constructor(root32, networkId, {
|
||||
walletId, storage, log = () => {}, onChange = () => {}, rpcUrl,
|
||||
derivationPath = "m/44'/501'/0'/0'",
|
||||
} = {}) {
|
||||
if (!walletId) throw new Error("chain-sol: walletId required");
|
||||
const net = NETWORKS[networkId];
|
||||
if (!net) throw new Error(`chain-sol: unknown network ${networkId}`);
|
||||
this.walletId = walletId;
|
||||
this.chain = "sol";
|
||||
this.network = net.id;
|
||||
this._net = net;
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.storage = scopedStorage(storage, `wallets/${walletId}/`);
|
||||
this._path = derivationPath;
|
||||
const derived = derivePath(root32, parseAllHardened(derivationPath));
|
||||
this._priv = derived.key; // 32-byte ed25519 seed
|
||||
this._pub = ed25519.getPublicKey(this._priv); // 32 bytes
|
||||
this.address = base58.encode(this._pub);
|
||||
this._root = new Uint8Array(root32);
|
||||
this._client = makeClient(String(rpcUrl || "").trim() || net.defaultRpc);
|
||||
this._state = {
|
||||
balance: { confirmed: 0, unconfirmed: 0 },
|
||||
history: [],
|
||||
tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}]
|
||||
height: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
};
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
setRpcUrl(url) {
|
||||
const v = String(url || "").trim() || this._net.defaultRpc;
|
||||
this._client = makeClient(v);
|
||||
this._emit();
|
||||
}
|
||||
_emit() { try { this.onChange(); } catch {} }
|
||||
|
||||
snapshot() {
|
||||
return {
|
||||
chain: "sol", network: this._net.id, ticker: "SOL", decimals: 9,
|
||||
address: this.address, addressIndex: 0, addressPath: this._path,
|
||||
balance: this._state.balance,
|
||||
height: this._state.height,
|
||||
history: this._state.history,
|
||||
tokens: this._state.tokens,
|
||||
scanning: this._state.scanning,
|
||||
error: this._state.error,
|
||||
server: this._client.url,
|
||||
rpcUrl: this._client.url,
|
||||
explorerTx: this._net.explorerTx,
|
||||
explorerAddr: this._net.explorerAddr,
|
||||
explorerSuffix: this._net.explorerCluster,
|
||||
faucet: this._net.faucet,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (this._state.scanning) return;
|
||||
this._state.scanning = true; this._state.error = null; this._emit();
|
||||
try {
|
||||
const [balRes, slot, tokens] = await Promise.all([
|
||||
this._client.call("getBalance", [this.address]),
|
||||
this._client.call("getSlot", []),
|
||||
this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }),
|
||||
]);
|
||||
// getBalance response: { context, value: lamports }
|
||||
const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0);
|
||||
this._state.balance = { confirmed: lamports, unconfirmed: 0 };
|
||||
this._state.height = Number(slot || 0);
|
||||
this._state.tokens = tokens;
|
||||
} catch (e) {
|
||||
this._state.error = e?.message || String(e);
|
||||
this.log("refresh failed:", this._state.error);
|
||||
} finally {
|
||||
this._state.scanning = false;
|
||||
this._emit();
|
||||
}
|
||||
}
|
||||
// getTokenAccountsByOwner + parse. Result shape:
|
||||
// {context, value: [{ pubkey, account: {data: {parsed: {info:{mint, tokenAmount:{amount,decimals,uiAmountString}}}, program, space}, executable, ...} }]}
|
||||
// We ask for jsonParsed encoding so the RPC does the layout heavy-lift.
|
||||
async _fetchTokens() {
|
||||
const known = spl.KNOWN_TOKENS[this._net.id] || {};
|
||||
const call = (programB58) => this._client.call("getTokenAccountsByOwner", [
|
||||
this.address,
|
||||
{ programId: programB58 },
|
||||
{ encoding: "jsonParsed", commitment: "confirmed" },
|
||||
]);
|
||||
const results = await Promise.all([
|
||||
call(spl.TOKEN_PROGRAM_ID_B58).catch(() => ({ value: [] })),
|
||||
call(spl.TOKEN_2022_PROGRAM_ID_B58).catch(() => ({ value: [] })),
|
||||
]);
|
||||
const out = [];
|
||||
for (let p = 0; p < results.length; p++) {
|
||||
const list = results[p]?.value || [];
|
||||
const isTk22 = p === 1;
|
||||
for (const it of list) {
|
||||
const info = it?.account?.data?.parsed?.info;
|
||||
if (!info) continue;
|
||||
const mint = String(info.mint || "");
|
||||
const dec = Number(info.tokenAmount?.decimals || 0);
|
||||
const rawAmount = String(info.tokenAmount?.amount || "0");
|
||||
const meta = known[mint];
|
||||
out.push({
|
||||
mint, tokenAccount: String(it.pubkey || ""),
|
||||
tokenProgram: isTk22 ? spl.TOKEN_2022_PROGRAM_ID_B58 : spl.TOKEN_PROGRAM_ID_B58,
|
||||
symbol: meta?.symbol || mint.slice(0, 6) + "…",
|
||||
name: meta?.name || null,
|
||||
decimals: dec,
|
||||
balance: rawAmount, // string (u64) to preserve precision
|
||||
isKnown: !!meta,
|
||||
isToken2022: isTk22,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Sort known tokens first, then by balance desc.
|
||||
out.sort((a, b) => (b.isKnown - a.isKnown) || (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1));
|
||||
return out;
|
||||
}
|
||||
schedulePoll(ms = 20_000) {
|
||||
clearTimeout(this._pollTimer);
|
||||
this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms);
|
||||
}
|
||||
|
||||
// Solana fees are (usually) 5000 lamports per signature; reserve that
|
||||
// in max-mode. Real fee comes from the network on broadcast.
|
||||
async plan({ to, amount, sendMax }) {
|
||||
const toBytes = base58.decode(String(to || "").trim());
|
||||
if (!toBytes || toBytes.length !== 32) throw new Error("bad Solana address");
|
||||
const bal = this._state.balance.confirmed || 0;
|
||||
const FEE = 5000; // lamports per signature
|
||||
let lamports;
|
||||
if (sendMax) {
|
||||
if (bal <= FEE) throw new Error("balance does not cover the fee");
|
||||
lamports = bal - FEE;
|
||||
} else {
|
||||
lamports = Math.round(Number(amount) || 0);
|
||||
if (!(lamports > 0)) throw new Error("amount must be > 0 lamports");
|
||||
if (lamports + FEE > bal) throw new Error("insufficient funds");
|
||||
}
|
||||
// Fetch the fresh blockhash at plan time so signing can use it
|
||||
// immediately — Solana blockhashes expire quickly (~150 slots ≈ 60s).
|
||||
const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {};
|
||||
if (!blockhash) throw new Error("could not fetch a recent blockhash");
|
||||
const recent = base58.decode(String(blockhash));
|
||||
return {
|
||||
_draft: { toBytes, lamports, recentBlockhash: recent },
|
||||
recipients: [{ to: base58.encode(toBytes), value: lamports }],
|
||||
fee: FEE, feeRate: FEE,
|
||||
inputs: [], change: 0,
|
||||
total: lamports + FEE,
|
||||
};
|
||||
}
|
||||
|
||||
async signAndBroadcast(plan) {
|
||||
const d = plan && plan._draft;
|
||||
if (!d) throw new Error("bad plan");
|
||||
const message = buildSolTransferMessage({
|
||||
fromPub: this._pub, toPub: d.toBytes, lamports: d.lamports,
|
||||
recentBlockhash: d.recentBlockhash,
|
||||
});
|
||||
const sig = ed25519.sign(message, this._priv); // 64 bytes
|
||||
// Full transaction wire format: sig-count || sigs... || message
|
||||
const sigCount = encodeCompactU16(1);
|
||||
const wire = concat(sigCount, sig, message);
|
||||
// Solana's sendTransaction accepts base58 (default) or base64 with
|
||||
// {encoding:"base64"} in the second arg; we use base58 for parity
|
||||
// with the rest of the ecosystem.
|
||||
const wireBase58 = base58.encode(wire);
|
||||
const txid = await this._client.call("sendTransaction", [wireBase58]);
|
||||
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||||
this.log("broadcast", txid);
|
||||
setTimeout(() => this.refresh(), 4000);
|
||||
return { txid };
|
||||
}
|
||||
|
||||
// ---- SPL token send -----------------------------------------------
|
||||
// Build a TransferChecked (+ optional CreateAssociatedTokenAccountIdempotent)
|
||||
// transaction moving `amount` (in raw token units) of a given mint to a
|
||||
// recipient's ATA. `mintB58` is the mint address as a base58 string;
|
||||
// decimals come from the sender's own token account (or the caller
|
||||
// passes them explicitly if the sender's ATA is empty).
|
||||
async planTokenTransfer({ mint, to, amount, decimals, tokenProgram }) {
|
||||
const mintB58 = String(mint || "");
|
||||
const mintBytes = base58.decode(mintB58);
|
||||
if (mintBytes.length !== 32) throw new Error("bad mint address");
|
||||
const recipientBytes = base58.decode(String(to || "").trim());
|
||||
if (recipientBytes.length !== 32) throw new Error("bad recipient address");
|
||||
// Resolve the token program for this mint from our own token list —
|
||||
// Token-2022 mints need the 2022 program in the transfer instruction.
|
||||
let tp = tokenProgram ? base58.decode(tokenProgram) : spl.TOKEN_PROGRAM_ID;
|
||||
let dec = decimals;
|
||||
const mine = (this._state.tokens || []).find((t) => t.mint === mintB58);
|
||||
if (mine) {
|
||||
tp = base58.decode(mine.tokenProgram);
|
||||
if (dec == null) dec = mine.decimals;
|
||||
}
|
||||
if (dec == null) throw new Error("token decimals unknown — no ATA on this wallet for that mint");
|
||||
const amt = BigInt(amount);
|
||||
if (amt <= 0n) throw new Error("amount must be > 0");
|
||||
if (mine && BigInt(mine.balance) < amt) throw new Error("insufficient token balance");
|
||||
const sourceATA = spl.associatedTokenAddress(this._pub, mintBytes, tp);
|
||||
const destATA = spl.associatedTokenAddress(recipientBytes, mintBytes, tp);
|
||||
// Ask the RPC whether the destination ATA already exists. If not,
|
||||
// prepend a CreateIdempotent instruction so the transfer succeeds
|
||||
// in one round-trip — the recipient never has to have interacted
|
||||
// with this mint before.
|
||||
const destATA_B58 = base58.encode(destATA);
|
||||
const info = await this._client.call("getAccountInfo", [destATA_B58, { encoding: "base64" }]);
|
||||
const destExists = !!(info && info.value);
|
||||
const instructions = [];
|
||||
if (!destExists) {
|
||||
instructions.push(spl.createATAIdempotentInstruction({
|
||||
payer: this._pub, ata: destATA, owner: recipientBytes, mint: mintBytes, tokenProgram: tp,
|
||||
}));
|
||||
}
|
||||
instructions.push(spl.transferCheckedInstruction({
|
||||
sourceATA, mint: mintBytes, destATA, owner: this._pub,
|
||||
amount: amt, decimals: dec, tokenProgram: tp,
|
||||
}));
|
||||
const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {};
|
||||
if (!blockhash) throw new Error("could not fetch a recent blockhash");
|
||||
const recentBlockhash = base58.decode(String(blockhash));
|
||||
return {
|
||||
_spl: { instructions, recentBlockhash, destExists, sourceATA, destATA },
|
||||
recipients: [{ to: base58.encode(recipientBytes), value: amt.toString() }],
|
||||
// Fee estimate: 5000 lamports per signature + ~2039280 rent-exempt
|
||||
// if we're creating a new ATA. Real fee still comes from the network.
|
||||
fee: destExists ? 5000 : 5000 + 2039280,
|
||||
feeRate: 5000,
|
||||
inputs: [],
|
||||
change: 0,
|
||||
total: amt.toString(),
|
||||
mint: mintB58,
|
||||
decimals: dec,
|
||||
};
|
||||
}
|
||||
async signAndBroadcastToken(plan) {
|
||||
const sp = plan && plan._spl;
|
||||
if (!sp) throw new Error("bad token plan");
|
||||
const message = spl.buildMessage({
|
||||
feePayer: this._pub,
|
||||
instructions: sp.instructions,
|
||||
recentBlockhash: sp.recentBlockhash,
|
||||
});
|
||||
const sig = ed25519.sign(message, this._priv);
|
||||
const encodeCompactU16 = (n) => {
|
||||
const out = [];
|
||||
let rem = n;
|
||||
while (true) {
|
||||
let byte = rem & 0x7f; rem >>= 7;
|
||||
if (rem === 0) { out.push(byte); break; }
|
||||
byte |= 0x80; out.push(byte);
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
};
|
||||
const sigCount = encodeCompactU16(1);
|
||||
const wire = new Uint8Array(sigCount.length + 64 + message.length);
|
||||
wire.set(sigCount, 0);
|
||||
wire.set(sig, sigCount.length);
|
||||
wire.set(message, sigCount.length + 64);
|
||||
const wireB58 = base58.encode(wire);
|
||||
const txid = await this._client.call("sendTransaction", [wireB58]);
|
||||
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
|
||||
this.log("SPL broadcast", txid);
|
||||
setTimeout(() => this.refresh().catch(() => {}), 4000);
|
||||
return { txid };
|
||||
}
|
||||
|
||||
// Solana's convention: ed25519 signature over the raw message bytes,
|
||||
// returned as {publicKey, signature} both base58. Dapps that follow
|
||||
// the wallet-adapter standard verify against these.
|
||||
signMessage(message) {
|
||||
const bytes = new TextEncoder().encode(String(message));
|
||||
const sig = ed25519.sign(bytes, this._priv);
|
||||
return {
|
||||
address: this.address,
|
||||
publicKey: base58.encode(this._pub),
|
||||
signature: base58.encode(sig),
|
||||
};
|
||||
}
|
||||
|
||||
recovery() {
|
||||
return {
|
||||
accountPath: this._path,
|
||||
xpub: base58.encode(this._pub),
|
||||
xprv: Buffer.from(this._priv).toString("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
clearTimeout(this._pollTimer);
|
||||
try { this._priv && this._priv.fill(0); } catch {}
|
||||
try { this._root && this._root.fill(0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { SolWallet, NETWORKS };
|
||||
};
|
||||
353
lib/chain-tron.js
Normal file
353
lib/chain-tron.js
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
// Tron chain adapter (mainnet + Nile testnet). One address per wallet, in the
|
||||
// TronLink style: seed for this wallet → BIP32 → m/44'/195'/0'/0/0 → secp256k1
|
||||
// private key → uncompressed pubkey (drop 0x04) → keccak256 last 20 bytes →
|
||||
// prepend 0x41 → base58check → "T..." address.
|
||||
//
|
||||
// Balance and history come from TronGrid (read; no key required). Send is
|
||||
// build-locally-signed-locally-broadcast-remotely:
|
||||
// POST /wallet/createtransaction {owner_address, to_address, amount, visible:true}
|
||||
// → { raw_data, raw_data_hex, txID, ... }
|
||||
// sig = secp256k1.sign(sha256(raw_data_hex), privateKey, {format:"recovered"})
|
||||
// → 65 bytes [r||s||recid]
|
||||
// POST /wallet/broadcasttransaction {raw_data, raw_data_hex, txID, signature:[hexSig]}
|
||||
// → { result: true/false, txid/message }
|
||||
//
|
||||
// Nile and mainnet share the address format (both prefix 0x41). The RPC host
|
||||
// differs. Coin type 195 is used for both — the derivation-path *string* is
|
||||
// identical; the difference between mainnet and Nile is which sub-wallet the
|
||||
// user picked, which vault-derive purpose seeded it, and which RPC we hit.
|
||||
// Two addresses; a mainnet wallet's key cannot accidentally sign for Nile
|
||||
// (different key material entirely; the check is enforced at the seed layer).
|
||||
|
||||
const NETWORKS = {
|
||||
mainnet: {
|
||||
id: "mainnet",
|
||||
label: "Tron",
|
||||
ticker: "TRX",
|
||||
rpcBase: "https://api.trongrid.io",
|
||||
explorerTx: "https://tronscan.org/#/transaction/",
|
||||
explorerAddr: "https://tronscan.org/#/address/",
|
||||
faucet: null,
|
||||
},
|
||||
nile: {
|
||||
id: "nile",
|
||||
label: "Tron Nile testnet",
|
||||
ticker: "TRX",
|
||||
rpcBase: "https://nile.trongrid.io",
|
||||
explorerTx: "https://nile.tronscan.org/#/transaction/",
|
||||
explorerAddr: "https://nile.tronscan.org/#/address/",
|
||||
faucet: "https://nileex.io/join/getJoinPage",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = function makeTronAdapter({ HDKey, secp256k1, sha256, keccak_256, base58check }) {
|
||||
if (!HDKey || !secp256k1 || !sha256 || !keccak_256 || !base58check) {
|
||||
throw new Error("chain-tron: missing dep");
|
||||
}
|
||||
const bytesToHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
const hexToBytes = (h) => {
|
||||
const s = String(h).replace(/^0x/i, "");
|
||||
if (s.length % 2) throw new Error("hex: odd length");
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
};
|
||||
|
||||
// Derive the raw key material for a single Tron account.
|
||||
// root32: 32-byte HKDF child from api.vault.derive.
|
||||
// returns: { privateKey (Uint8Array 32), publicKey (Uint8Array 65 uncompressed),
|
||||
// addressBytes (Uint8Array 21, prefix 0x41 || h20), address (base58) }
|
||||
function deriveAccount(root32) {
|
||||
const master = HDKey.fromMasterSeed(root32);
|
||||
// Tron follows Ethereum-style non-hardened branch/index: m/44'/195'/0'/0/0.
|
||||
const node = master.derive("m/44'/195'/0'/0/0");
|
||||
const priv = node.privateKey;
|
||||
// Uncompressed pub is 65 bytes with a 0x04 prefix; drop it to feed keccak256.
|
||||
const pubUncompressed = secp256k1.getPublicKey(priv, false);
|
||||
const inner = pubUncompressed.slice(1); // 64 bytes
|
||||
const kh = keccak_256(inner); // 32 bytes
|
||||
const h20 = kh.slice(kh.length - 20);
|
||||
const addrBytes = new Uint8Array(21);
|
||||
addrBytes[0] = 0x41;
|
||||
addrBytes.set(h20, 1);
|
||||
const address = base58check.encodeCheck(addrBytes);
|
||||
return { privateKey: priv, publicKey: pubUncompressed, addressBytes: addrBytes, address, node };
|
||||
}
|
||||
|
||||
// "T..." → 21-byte payload (0x41 || h20). Throws on bad checksum / prefix.
|
||||
function decodeAddress(str) {
|
||||
const payload = base58check.decodeCheck(String(str).trim());
|
||||
if (payload.length !== 21) throw new Error("bad address length");
|
||||
if (payload[0] !== 0x41) throw new Error("bad address prefix (want 0x41 / T…)");
|
||||
return payload;
|
||||
}
|
||||
function hexToAddress(hex) {
|
||||
const b = hexToBytes(hex);
|
||||
if (b.length !== 21 || b[0] !== 0x41) throw new Error("bad address hex");
|
||||
return base58check.encodeCheck(b);
|
||||
}
|
||||
|
||||
function makeClient(networkId) {
|
||||
const net = NETWORKS[networkId];
|
||||
if (!net) throw new Error(`unknown Tron network ${networkId}`);
|
||||
async function rpc(pathPart, body) {
|
||||
const url = net.rpcBase + pathPart;
|
||||
let r;
|
||||
try { r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body || {}) }); }
|
||||
catch (e) { throw new Error(`network: ${e?.message || e}`); }
|
||||
if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function get(pathPart) {
|
||||
const url = net.rpcBase + pathPart;
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${pathPart}: HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
return { net, rpc, get };
|
||||
}
|
||||
|
||||
class TronWallet {
|
||||
constructor(root32, networkId, { storage, log = () => {}, onChange = () => {} } = {}) {
|
||||
this.storage = storage;
|
||||
this.log = log;
|
||||
this.onChange = onChange;
|
||||
this.client = makeClient(networkId);
|
||||
this.net = this.client.net;
|
||||
const acc = deriveAccount(root32);
|
||||
// Root is stored so future revs can rotate accounts; the derived priv
|
||||
// is what actually signs. Both wiped in dispose().
|
||||
this._root = new Uint8Array(root32);
|
||||
this._priv = acc.privateKey;
|
||||
this.publicKey = acc.publicKey;
|
||||
this.addressBytes = acc.addressBytes;
|
||||
this.address = acc.address;
|
||||
this.state = {
|
||||
balance: { confirmed: 0, unconfirmed: 0 },
|
||||
history: [],
|
||||
height: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
server: this.net.rpcBase,
|
||||
};
|
||||
this._pollTimer = null;
|
||||
}
|
||||
|
||||
// Public snapshot (no key material).
|
||||
snapshot() {
|
||||
return {
|
||||
chain: "trx",
|
||||
network: this.net.id,
|
||||
ticker: this.net.ticker,
|
||||
address: this.address,
|
||||
addressIndex: 0,
|
||||
addressPath: "m/44'/195'/0'/0/0",
|
||||
balance: this.state.balance,
|
||||
height: this.state.height,
|
||||
history: this.state.history,
|
||||
scanning: this.state.scanning,
|
||||
error: this.state.error,
|
||||
server: this.state.server,
|
||||
explorerTx: this.net.explorerTx,
|
||||
explorerAddr: this.net.explorerAddr,
|
||||
faucet: this.net.faucet,
|
||||
decimals: 6,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(_full = false) {
|
||||
if (this.state.scanning) return;
|
||||
this.state.scanning = true; this.state.error = null; this.onChange();
|
||||
try {
|
||||
const [acc, txs] = await Promise.all([this._fetchAccount(), this._fetchHistory()]);
|
||||
// TronGrid returns balance in "sun" (1 TRX = 1_000_000 sun).
|
||||
const sun = Number(acc?.balance || 0);
|
||||
this.state.balance = { confirmed: sun, unconfirmed: 0 };
|
||||
this.state.history = txs;
|
||||
// Block height comes from any recent tx or a getnowblock call.
|
||||
try {
|
||||
const nb = await this.client.rpc("/wallet/getnowblock", {});
|
||||
this.state.height = Number(nb?.block_header?.raw_data?.number || 0);
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
this.state.error = e?.message || String(e);
|
||||
this.log("refresh failed:", this.state.error);
|
||||
} finally {
|
||||
this.state.scanning = false;
|
||||
this.onChange();
|
||||
}
|
||||
}
|
||||
|
||||
schedulePoll(ms = 20_000) {
|
||||
clearTimeout(this._pollTimer);
|
||||
this._pollTimer = setTimeout(() => this.refresh(false).finally(() => this.schedulePoll(ms)), ms);
|
||||
}
|
||||
|
||||
async _fetchAccount() {
|
||||
const body = { address: this.address, visible: true };
|
||||
// /wallet/getaccount returns {} for never-funded addresses.
|
||||
return this.client.rpc("/wallet/getaccount", body);
|
||||
}
|
||||
|
||||
async _fetchHistory() {
|
||||
const url = `/v1/accounts/${encodeURIComponent(this.address)}/transactions?limit=25`;
|
||||
let raw;
|
||||
try { raw = await this.client.get(url); } catch (e) { this.log("history failed:", e?.message || e); return []; }
|
||||
const list = Array.isArray(raw?.data) ? raw.data : [];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return list.map((t) => this._describeTx(t, now)).filter(Boolean);
|
||||
}
|
||||
|
||||
_describeTx(t, nowSec) {
|
||||
const contract = t?.raw_data?.contract?.[0];
|
||||
const type = contract?.type;
|
||||
const value = contract?.parameter?.value || {};
|
||||
const txID = t.txID || t.txid;
|
||||
const time = Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000);
|
||||
const conf = t.ret && Array.isArray(t.ret) ? (t.ret[0]?.contractRet === "SUCCESS" ? 1 : -1) : 0;
|
||||
if (type === "TransferContract") {
|
||||
// owner_address / to_address come as hex here (prefixed 41), regardless
|
||||
// of visible:true (that flag only affects some endpoints).
|
||||
let ownerB58 = "", toB58 = "";
|
||||
try { ownerB58 = value.owner_address ? hexToAddress(value.owner_address) : ""; } catch {}
|
||||
try { toB58 = value.to_address ? hexToAddress(value.to_address) : ""; } catch {}
|
||||
const amount = Number(value.amount || 0);
|
||||
const inc = toB58 === this.address;
|
||||
return {
|
||||
txid: txID,
|
||||
delta: inc ? amount : -amount,
|
||||
to: inc ? null : toB58,
|
||||
from: inc ? ownerB58 : null,
|
||||
fee: t.net_fee || t.energy_fee || null,
|
||||
time: time || 0,
|
||||
confirmations: conf > 0 ? 1 : (conf < 0 ? 0 : 0),
|
||||
status: conf > 0 ? "confirmed" : (conf < 0 ? "failed" : "pending"),
|
||||
kind: "transfer",
|
||||
};
|
||||
}
|
||||
// Non-transfer contracts (delegation, votes, TRC20) — surface as a
|
||||
// neutral entry so users see something happened without exaggerating.
|
||||
return {
|
||||
txid: txID,
|
||||
delta: 0,
|
||||
to: null, from: null, fee: null,
|
||||
time: time || 0,
|
||||
confirmations: conf > 0 ? 1 : 0,
|
||||
status: conf > 0 ? "confirmed" : "other",
|
||||
kind: type || "contract",
|
||||
};
|
||||
}
|
||||
|
||||
// Preview a send: no side effects, no signature. Returns the fields the
|
||||
// panel needs plus the raw createtransaction result cached under _draft
|
||||
// so signAndBroadcast doesn't re-fetch.
|
||||
async plan({ to, amount, sendMax }) {
|
||||
if (!to) throw new Error("recipient required");
|
||||
const dest = decodeAddress(to);
|
||||
const balance = this.state.balance.confirmed;
|
||||
// Tron doesn't have a fee-market for plain TRX transfers between accounts
|
||||
// that carry enough bandwidth. For untouched addresses the network
|
||||
// consumes a fixed 100_000 sun (0.1 TRX) burn from the sender if no
|
||||
// free bandwidth is available. We show that as an upper-bound estimate.
|
||||
const FEE_EST = 100_000;
|
||||
let value;
|
||||
if (sendMax) {
|
||||
value = Math.max(0, balance - FEE_EST);
|
||||
} else {
|
||||
value = Math.round(Number(amount) || 0);
|
||||
}
|
||||
if (!(value > 0)) throw new Error("amount must be > 0 sun");
|
||||
if (value + FEE_EST > balance) throw new Error("insufficient funds");
|
||||
const body = { owner_address: this.address, to_address: base58check.encodeCheck(dest), amount: value, visible: true };
|
||||
const draft = await this.client.rpc("/wallet/createtransaction", body);
|
||||
if (draft?.Error || !draft?.raw_data_hex) {
|
||||
throw new Error("createtransaction: " + (draft?.Error || "empty response"));
|
||||
}
|
||||
const plan = {
|
||||
recipients: [{ to: base58check.encodeCheck(dest), value }],
|
||||
fee: FEE_EST,
|
||||
feeRate: 1,
|
||||
total: value + FEE_EST,
|
||||
inputs: [],
|
||||
_draft: draft,
|
||||
};
|
||||
return plan;
|
||||
}
|
||||
|
||||
// Sign the raw_data_hex bytes with the wallet key and POST the signed tx.
|
||||
async signAndBroadcast(plan) {
|
||||
const draft = plan && plan._draft;
|
||||
if (!draft || !draft.raw_data_hex) throw new Error("bad plan");
|
||||
const rawBytes = hexToBytes(draft.raw_data_hex);
|
||||
const digest = sha256(rawBytes);
|
||||
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
|
||||
// Recovered format from noble is 65 bytes: [recid || r(32) || s(32)]. Tron
|
||||
// wants [r(32) || s(32) || recid]. Reorder in place.
|
||||
const trxSig = new Uint8Array(65);
|
||||
trxSig.set(sig.subarray(1), 0);
|
||||
trxSig[64] = sig[0];
|
||||
const body = {
|
||||
raw_data: draft.raw_data,
|
||||
raw_data_hex: draft.raw_data_hex,
|
||||
txID: draft.txID,
|
||||
visible: true,
|
||||
signature: [bytesToHex(trxSig)],
|
||||
};
|
||||
const r = await this.client.rpc("/wallet/broadcasttransaction", body);
|
||||
if (r?.result !== true) {
|
||||
const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected");
|
||||
throw new Error("broadcast: " + msg);
|
||||
}
|
||||
// Refresh soon so history/balance catch up.
|
||||
setTimeout(() => this.refresh(false), 2500);
|
||||
return { txid: draft.txID };
|
||||
}
|
||||
|
||||
// BIP137-style signing isn't standard on Tron; dapps use signMessageV2
|
||||
// (tronWeb.trx.signMessageV2), which is a raw ECDSA over sha256 of the
|
||||
// message bytes with a Tron prefix "\x19TRON Signed Message:\n32". Kept
|
||||
// simple here: signMessageV2 always asks the user.
|
||||
signMessageV2(message) {
|
||||
const enc = new TextEncoder();
|
||||
const bodyBytes = enc.encode(String(message));
|
||||
const prefix = enc.encode("\x19TRON Signed Message:\n" + bodyBytes.length);
|
||||
const buf = new Uint8Array(prefix.length + bodyBytes.length);
|
||||
buf.set(prefix, 0); buf.set(bodyBytes, prefix.length);
|
||||
const digest = keccak_256(buf);
|
||||
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
|
||||
const out = new Uint8Array(65);
|
||||
out.set(sig.subarray(1), 0);
|
||||
out[64] = sig[0] + 27; // Ethereum-style v = 27 + recid
|
||||
return { address: this.address, signature: "0x" + bytesToHex(out) };
|
||||
}
|
||||
|
||||
// Sign an arbitrary raw_data_hex the dapp built with its own tronWeb.
|
||||
// The caller must have already presented an approval overlay.
|
||||
signRawData(rawDataHex) {
|
||||
const rawBytes = hexToBytes(rawDataHex);
|
||||
const digest = sha256(rawBytes);
|
||||
const sig = secp256k1.sign(digest, this._priv, { prehash: false, lowS: false, format: "recovered" });
|
||||
const trxSig = new Uint8Array(65);
|
||||
trxSig.set(sig.subarray(1), 0);
|
||||
trxSig[64] = sig[0];
|
||||
return bytesToHex(trxSig);
|
||||
}
|
||||
|
||||
async broadcastSignedTx(signedTx) {
|
||||
const r = await this.client.rpc("/wallet/broadcasttransaction", signedTx);
|
||||
if (r?.result !== true) {
|
||||
const msg = r?.message ? Buffer.from(r.message, "hex").toString("utf8") : (r?.code || "broadcast rejected");
|
||||
throw new Error("broadcast: " + msg);
|
||||
}
|
||||
return { txid: signedTx.txID };
|
||||
}
|
||||
|
||||
dispose() {
|
||||
clearTimeout(this._pollTimer);
|
||||
try { this._priv && this._priv.fill(0); } catch {}
|
||||
try { this._root && this._root.fill(0); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return { TronWallet, deriveAccount, decodeAddress, hexToAddress, NETWORKS };
|
||||
};
|
||||
10
lib/dgb/core/address.d.ts
vendored
Normal file
10
lib/dgb/core/address.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { BIP32Interface } from 'bip32';
|
||||
import { type Network } from './network.js';
|
||||
export declare function p2pkhAddress(node: BIP32Interface, network?: Network): string;
|
||||
export declare function p2shP2wpkhAddress(node: BIP32Interface, network?: Network): string;
|
||||
export declare function p2shP2wpkhAddressesBoth(node: BIP32Interface): {
|
||||
modern: string;
|
||||
legacy: string;
|
||||
};
|
||||
export declare function p2wpkhAddress(node: BIP32Interface, network?: Network): string;
|
||||
export declare function p2trAddress(node: BIP32Interface, network?: Network): string;
|
||||
53
lib/dgb/core/address.js
Normal file
53
lib/dgb/core/address.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { payments, initEccLib } from 'bitcoinjs-lib';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { digibyte, digibyteLegacyP2SH } from './network.js';
|
||||
// Required for Taproot address derivation (P2TR).
|
||||
initEccLib(ecc);
|
||||
export function p2pkhAddress(node, network = digibyte) {
|
||||
const { address } = payments.p2pkh({
|
||||
pubkey: Buffer.from(node.publicKey),
|
||||
network,
|
||||
});
|
||||
if (!address)
|
||||
throw new Error('p2pkh derivation returned no address');
|
||||
return address;
|
||||
}
|
||||
export function p2shP2wpkhAddress(node, network = digibyte) {
|
||||
const redeem = payments.p2wpkh({
|
||||
pubkey: Buffer.from(node.publicKey),
|
||||
network,
|
||||
});
|
||||
const { address } = payments.p2sh({ redeem, network });
|
||||
if (!address)
|
||||
throw new Error('p2sh-p2wpkh derivation returned no address');
|
||||
return address;
|
||||
}
|
||||
// Derive the "S..." (current DGB, scriptHash 0x3f) AND "3..." (legacy
|
||||
// Bitcoin-compatible, scriptHash 0x05) BIP49 addresses for the same
|
||||
// key. Ian Coleman's BIP39 tool and several older wallets generate the
|
||||
// legacy "3..." variant, so any seed-recovery scan must check both.
|
||||
export function p2shP2wpkhAddressesBoth(node) {
|
||||
return {
|
||||
modern: p2shP2wpkhAddress(node, digibyte),
|
||||
legacy: p2shP2wpkhAddress(node, digibyteLegacyP2SH),
|
||||
};
|
||||
}
|
||||
export function p2wpkhAddress(node, network = digibyte) {
|
||||
const { address } = payments.p2wpkh({
|
||||
pubkey: Buffer.from(node.publicKey),
|
||||
network,
|
||||
});
|
||||
if (!address)
|
||||
throw new Error('p2wpkh derivation returned no address');
|
||||
return address;
|
||||
}
|
||||
// BIP86 Taproot address using the x-only pubkey with no script tree,
|
||||
// which applies the standard BIP86 tweak internally in bitcoinjs-lib.
|
||||
export function p2trAddress(node, network = digibyte) {
|
||||
const internalPubkey = Buffer.from(node.publicKey.subarray(1, 33));
|
||||
const { address } = payments.p2tr({ internalPubkey, network });
|
||||
if (!address)
|
||||
throw new Error('p2tr derivation returned no address');
|
||||
return address;
|
||||
}
|
||||
//# sourceMappingURL=address.js.map
|
||||
5
lib/dgb/core/descriptor.d.ts
vendored
Normal file
5
lib/dgb/core/descriptor.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { BIP32Interface } from 'bip32';
|
||||
import type { Purpose } from './hd.js';
|
||||
export declare function derivationPath(purpose: Purpose, account: number, change: 0 | 1, index: number): string;
|
||||
export declare function accountXpub(accountNode: BIP32Interface): string;
|
||||
export declare function accountXprv(accountNode: BIP32Interface): string;
|
||||
18
lib/dgb/core/descriptor.js
Normal file
18
lib/dgb/core/descriptor.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { DGB_COIN_TYPE } from './network.js';
|
||||
// Convenience helper: full derivation-path string for a specific
|
||||
// address, e.g. m/84'/20'/0'/0/5.
|
||||
export function derivationPath(purpose, account, change, index) {
|
||||
return `m/${purpose}'/${DGB_COIN_TYPE}'/${account}'/${change}/${index}`;
|
||||
}
|
||||
// xpub for an account, ready to be shared with a watch-only or
|
||||
// external indexer. Never share the corresponding xprv.
|
||||
export function accountXpub(accountNode) {
|
||||
return accountNode.neutered().toBase58();
|
||||
}
|
||||
export function accountXprv(accountNode) {
|
||||
if (!accountNode.privateKey) {
|
||||
throw new Error('Node has no private key; cannot export xprv');
|
||||
}
|
||||
return accountNode.toBase58();
|
||||
}
|
||||
//# sourceMappingURL=descriptor.js.map
|
||||
8
lib/dgb/core/hd.d.ts
vendored
Normal file
8
lib/dgb/core/hd.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { type BIP32Interface } from 'bip32';
|
||||
import { type Network } from './network.js';
|
||||
export type Purpose = 44 | 49 | 84 | 86;
|
||||
export declare const PURPOSE_LABEL: Record<Purpose, string>;
|
||||
export declare function rootFromSeed(seed: Buffer, network?: Network): BIP32Interface;
|
||||
export declare function accountNode(root: BIP32Interface, purpose: Purpose, account?: number): BIP32Interface;
|
||||
export declare function addressNode(account: BIP32Interface, change: 0 | 1, index: number): BIP32Interface;
|
||||
export type { BIP32Interface };
|
||||
24
lib/dgb/core/hd.js
Normal file
24
lib/dgb/core/hd.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { BIP32Factory } from 'bip32';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { digibyte, DGB_COIN_TYPE } from './network.js';
|
||||
const bip32 = BIP32Factory(ecc);
|
||||
export const PURPOSE_LABEL = {
|
||||
44: 'BIP44 legacy P2PKH',
|
||||
49: 'BIP49 P2SH-wrapped SegWit',
|
||||
84: 'BIP84 native SegWit v0',
|
||||
86: 'BIP86 Taproot (SegWit v1)',
|
||||
};
|
||||
export function rootFromSeed(seed, network = digibyte) {
|
||||
return bip32.fromSeed(seed, network);
|
||||
}
|
||||
// Standard account-level derivation: m/purpose'/coin'/account'.
|
||||
// account defaults to 0 (the first account).
|
||||
export function accountNode(root, purpose, account = 0) {
|
||||
return root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/${account}'`);
|
||||
}
|
||||
// Address-level derivation from an account node.
|
||||
// change = 0 for external (receive) addresses, 1 for internal (change).
|
||||
export function addressNode(account, change, index) {
|
||||
return account.derive(change).derive(index);
|
||||
}
|
||||
//# sourceMappingURL=hd.js.map
|
||||
6
lib/dgb/core/index.d.ts
vendored
Normal file
6
lib/dgb/core/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './network.js';
|
||||
export * from './seed.js';
|
||||
export * from './hd.js';
|
||||
export * from './address.js';
|
||||
export * from './wif.js';
|
||||
export * from './descriptor.js';
|
||||
7
lib/dgb/core/index.js
Normal file
7
lib/dgb/core/index.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export * from './network.js';
|
||||
export * from './seed.js';
|
||||
export * from './hd.js';
|
||||
export * from './address.js';
|
||||
export * from './wif.js';
|
||||
export * from './descriptor.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
13
lib/dgb/core/network.d.ts
vendored
Normal file
13
lib/dgb/core/network.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { networks } from 'bitcoinjs-lib';
|
||||
export type Network = (typeof networks)['bitcoin'];
|
||||
export declare const digibyte: Network;
|
||||
export declare const digibyteLegacyP2SH: Network;
|
||||
export declare const digibyteLegacyWIF: Network;
|
||||
export declare const digibyteTestnet: Network;
|
||||
export declare const DGB_COIN_TYPE = 20;
|
||||
export declare const DGB_P2P: {
|
||||
readonly magic: 3669410810;
|
||||
readonly defaultPort: 12024;
|
||||
readonly rpcPort: 14022;
|
||||
readonly dnsSeeds: readonly ["seed.digibyte.io", "seed.diginode.tools", "seed.digibyte.link", "seed.aroundtheblock.app", "seed.tuyul.cc"];
|
||||
};
|
||||
74
lib/dgb/core/network.js
Normal file
74
lib/dgb/core/network.js
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// DigiByte mainnet parameters. Sourced from
|
||||
// github.com/DigiByte-Core/digibyte src/kernel/chainparams.cpp CMainParams.
|
||||
//
|
||||
// Note on scriptHash: DGB Core defines BOTH SCRIPT_ADDRESS = 0x3f ("S..."
|
||||
// addresses, the current default) and SCRIPT_ADDRESS2 = 0x05 ("3..."
|
||||
// addresses, kept for Bitcoin compatibility). Older BIP39 tools and
|
||||
// wallets that forked bitcoinjs-lib params generate 3-addresses; current
|
||||
// DGB Core generates S-addresses. `digibyte` below uses the current
|
||||
// default; use `digibyteLegacyP2SH` when recovering from Ian Coleman's
|
||||
// BIP39 tool, older Atomic/Exodus vintages, or anything else that
|
||||
// inherited Bitcoin's 0x05 P2SH byte. Both are valid on-chain.
|
||||
export const digibyte = {
|
||||
messagePrefix: '\x19DigiByte Signed Message:\n',
|
||||
bech32: 'dgb',
|
||||
bip32: {
|
||||
public: 0x0488b21e,
|
||||
private: 0x0488ade4,
|
||||
},
|
||||
pubKeyHash: 0x1e,
|
||||
scriptHash: 0x3f,
|
||||
wif: 0x80,
|
||||
};
|
||||
// Same as `digibyte`, but with the legacy Bitcoin-compatible P2SH
|
||||
// version byte. Use for producing/scanning "3..."-style P2SH addresses
|
||||
// during seed recovery from tools that predate the 0x3f switch.
|
||||
// DGB Core keeps SCRIPT_ADDRESS_OLD = 5 for backward compatibility.
|
||||
export const digibyteLegacyP2SH = {
|
||||
...digibyte,
|
||||
scriptHash: 0x05,
|
||||
};
|
||||
// Same as `digibyte`, but with the legacy WIF version byte.
|
||||
// DGB Core keeps SECRET_KEY_OLD = 158 (0x9e) for backward compatibility.
|
||||
// Older DGB tools may have exported private keys with this prefix.
|
||||
// The wallet's WIF-import path should try both `digibyte` and this
|
||||
// variant before rejecting a key.
|
||||
export const digibyteLegacyWIF = {
|
||||
...digibyte,
|
||||
wif: 0x9e,
|
||||
};
|
||||
// DigiByte testnet parameters. From CTestNetParams in chainparams.cpp.
|
||||
export const digibyteTestnet = {
|
||||
messagePrefix: '\x19DigiByte Signed Message:\n',
|
||||
bech32: 'dgbt',
|
||||
bip32: {
|
||||
public: 0x043587cf,
|
||||
private: 0x04358394,
|
||||
},
|
||||
pubKeyHash: 0x7e,
|
||||
scriptHash: 0x8c,
|
||||
wif: 0xef,
|
||||
};
|
||||
// SLIP-0044 registered coin type.
|
||||
export const DGB_COIN_TYPE = 20;
|
||||
// P2P network constants (unused by @dgb-wallet/core itself, exposed for
|
||||
// the P2P client package that will consume them).
|
||||
export const DGB_P2P = {
|
||||
// pchMessageStart in DGB Core chainparams.cpp is the byte sequence
|
||||
// 0xFA 0xC3 0xB6 0xDA on the wire. `writeUInt32LE(magic)` writes
|
||||
// least-significant-byte first, so the integer value stored here must
|
||||
// be the LE-reading: 0xDAB6C3FA (byte 0 = 0xFA, byte 3 = 0xDA).
|
||||
// Getting this wrong means peers see wrong-network frames and close
|
||||
// immediately at handshake.
|
||||
magic: 0xdab6c3fa,
|
||||
defaultPort: 12024,
|
||||
rpcPort: 14022,
|
||||
dnsSeeds: [
|
||||
'seed.digibyte.io',
|
||||
'seed.diginode.tools',
|
||||
'seed.digibyte.link',
|
||||
'seed.aroundtheblock.app',
|
||||
'seed.tuyul.cc',
|
||||
],
|
||||
};
|
||||
//# sourceMappingURL=network.js.map
|
||||
1
lib/dgb/core/package.json
Normal file
1
lib/dgb/core/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"module","main":"./index.js"}
|
||||
4
lib/dgb/core/seed.d.ts
vendored
Normal file
4
lib/dgb/core/seed.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type SeedStrength = 128 | 160 | 192 | 224 | 256;
|
||||
export declare function generateSeedPhrase(strength?: SeedStrength): string;
|
||||
export declare function validateSeedPhrase(phrase: string, wordlist?: string[]): boolean;
|
||||
export declare function seedFromPhrase(phrase: string, passphrase?: string): Promise<Buffer>;
|
||||
17
lib/dgb/core/seed.js
Normal file
17
lib/dgb/core/seed.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { generateMnemonic, validateMnemonic, mnemonicToSeed, wordlists, } from 'bip39';
|
||||
// Word count → entropy strength for BIP39.
|
||||
// 12 → 128, 15 → 160, 18 → 192, 21 → 224, 24 → 256.
|
||||
export function generateSeedPhrase(strength = 128) {
|
||||
return generateMnemonic(strength);
|
||||
}
|
||||
// BIP39 checksum + wordlist validation. Returns false for typos, bad
|
||||
// word counts, and out-of-wordlist words.
|
||||
export function validateSeedPhrase(phrase, wordlist = wordlists.english) {
|
||||
return validateMnemonic(phrase.trim(), wordlist);
|
||||
}
|
||||
// BIP39 seed derivation. Passphrase is the optional "25th word";
|
||||
// changing it produces a different wallet from the same mnemonic.
|
||||
export async function seedFromPhrase(phrase, passphrase = '') {
|
||||
return mnemonicToSeed(phrase.trim(), passphrase);
|
||||
}
|
||||
//# sourceMappingURL=seed.js.map
|
||||
27
lib/dgb/core/wif.d.ts
vendored
Normal file
27
lib/dgb/core/wif.d.ts
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { type ECPairInterface } from 'ecpair';
|
||||
import { type Network } from './network.js';
|
||||
export type WifImportResult = {
|
||||
keyPair: ECPairInterface;
|
||||
variant: 'modern' | 'legacy';
|
||||
};
|
||||
export declare function importWif(wif: string): WifImportResult;
|
||||
export declare function importWifStrict(wif: string, network?: Network): ECPairInterface;
|
||||
export declare function exportWif(keyPair: ECPairInterface): string;
|
||||
export interface PubkeyAddresses {
|
||||
p2pkh: string;
|
||||
p2shP2wpkhModern: string;
|
||||
p2shP2wpkhLegacy: string;
|
||||
p2wpkh: string;
|
||||
p2tr: string;
|
||||
scripts: {
|
||||
p2pkh: string;
|
||||
p2shP2wpkh: string;
|
||||
p2wpkh: string;
|
||||
p2tr: string;
|
||||
};
|
||||
}
|
||||
export declare function addressesForPubkey(pubkey: Buffer, network?: Network): PubkeyAddresses;
|
||||
export interface WifImportWithAddresses extends WifImportResult, PubkeyAddresses {
|
||||
}
|
||||
export declare function importWifWithAddresses(wif: string): WifImportWithAddresses;
|
||||
export type { ECPairInterface };
|
||||
72
lib/dgb/core/wif.js
Normal file
72
lib/dgb/core/wif.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ECPairFactory } from 'ecpair';
|
||||
import * as ecc from '@bitcoinerlab/secp256k1';
|
||||
import { payments, initEccLib } from 'bitcoinjs-lib';
|
||||
import { digibyte, digibyteLegacyP2SH, digibyteLegacyWIF } from './network.js';
|
||||
const ECPair = ECPairFactory(ecc);
|
||||
initEccLib(ecc);
|
||||
// Import a DigiByte private key in WIF (Wallet Import Format).
|
||||
// Tries the modern 0x80 prefix first, falls back to the legacy 0x9e
|
||||
// prefix that older DGB tools produced. Rejects anything else with a
|
||||
// clear message that identifies the network mismatch.
|
||||
export function importWif(wif) {
|
||||
const trimmed = wif.trim();
|
||||
try {
|
||||
return { keyPair: ECPair.fromWIF(trimmed, digibyte), variant: 'modern' };
|
||||
}
|
||||
catch {
|
||||
// fall through to legacy attempt
|
||||
}
|
||||
try {
|
||||
return { keyPair: ECPair.fromWIF(trimmed, digibyteLegacyWIF), variant: 'legacy' };
|
||||
}
|
||||
catch (e) {
|
||||
throw new Error(`Not a valid DigiByte WIF (tried both current 0x80 and legacy 0x9e prefixes). ` +
|
||||
`Underlying error: ${e.message}. Check the key is for DGB mainnet, not testnet or another chain.`);
|
||||
}
|
||||
}
|
||||
// Import against a specific network only (advanced / testing).
|
||||
export function importWifStrict(wif, network = digibyte) {
|
||||
return ECPair.fromWIF(wif.trim(), network);
|
||||
}
|
||||
export function exportWif(keyPair) {
|
||||
return keyPair.toWIF();
|
||||
}
|
||||
export function addressesForPubkey(pubkey, network = digibyte) {
|
||||
const p2pkh = payments.p2pkh({ pubkey, network });
|
||||
const wpkhRedeem = payments.p2wpkh({ pubkey, network });
|
||||
// bitcoinjs-lib enforces `redeem.network === outerNetwork` (identity
|
||||
// comparison, not shape). To render the legacy 3-prefix P2SH address
|
||||
// we need a fresh redeem whose .network property is the legacy variant
|
||||
// — same bytes on the wire, different object identity.
|
||||
const wpkhRedeemLegacy = payments.p2wpkh({ pubkey, network: digibyteLegacyP2SH });
|
||||
const p2shModern = payments.p2sh({ redeem: wpkhRedeem, network });
|
||||
const p2shLegacy = payments.p2sh({ redeem: wpkhRedeemLegacy, network: digibyteLegacyP2SH });
|
||||
const p2wpkh = payments.p2wpkh({ pubkey, network });
|
||||
const p2tr = payments.p2tr({ internalPubkey: pubkey.subarray(1, 33), network });
|
||||
if (!p2pkh.address || !p2shModern.address || !p2shLegacy.address || !p2wpkh.address || !p2tr.address) {
|
||||
throw new Error('bitcoinjs-lib returned an empty address for one of the payment types');
|
||||
}
|
||||
if (!p2pkh.output || !p2shModern.output || !p2wpkh.output || !p2tr.output) {
|
||||
throw new Error('bitcoinjs-lib returned an empty scriptPubKey for one of the payment types');
|
||||
}
|
||||
return {
|
||||
p2pkh: p2pkh.address,
|
||||
p2shP2wpkhModern: p2shModern.address,
|
||||
p2shP2wpkhLegacy: p2shLegacy.address,
|
||||
p2wpkh: p2wpkh.address,
|
||||
p2tr: p2tr.address,
|
||||
scripts: {
|
||||
p2pkh: Buffer.from(p2pkh.output).toString('hex'),
|
||||
p2shP2wpkh: Buffer.from(p2shModern.output).toString('hex'),
|
||||
p2wpkh: Buffer.from(p2wpkh.output).toString('hex'),
|
||||
p2tr: Buffer.from(p2tr.output).toString('hex'),
|
||||
},
|
||||
};
|
||||
}
|
||||
export function importWifWithAddresses(wif) {
|
||||
const imported = importWif(wif);
|
||||
const pubkey = Buffer.from(imported.keyPair.publicKey);
|
||||
const addrs = addressesForPubkey(pubkey);
|
||||
return { ...imported, ...addrs };
|
||||
}
|
||||
//# sourceMappingURL=wif.js.map
|
||||
4
lib/dgb/psbt/build.d.ts
vendored
Normal file
4
lib/dgb/psbt/build.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { Psbt } from 'bitcoinjs-lib';
|
||||
import { type Network } from '@dgb-wallet/core';
|
||||
import type { BuildParams } from './types.js';
|
||||
export declare function buildPsbt(params: BuildParams, network?: Network): Psbt;
|
||||
56
lib/dgb/psbt/build.js
Normal file
56
lib/dgb/psbt/build.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { Psbt } from 'bitcoinjs-lib';
|
||||
import { digibyte } from '../core/index.js';
|
||||
// Construct an unsigned PSBT from a set of UTXOs and destination outputs.
|
||||
// Does not add a change output — the caller decides change amount and
|
||||
// address. Does not compute fees — the caller must have already subtracted
|
||||
// fee from outputs.
|
||||
export function buildPsbt(params, network = digibyte) {
|
||||
const psbt = new Psbt({ network });
|
||||
const inputs = params.sortBip69 === false ? params.inputs : sortInputs(params.inputs);
|
||||
const outputs = params.sortBip69 === false ? params.outputs : sortOutputs(params.outputs);
|
||||
for (const u of inputs) {
|
||||
psbt.addInput(inputToPsbtInput(u));
|
||||
}
|
||||
for (const o of outputs) {
|
||||
psbt.addOutput({ address: o.address, value: o.value });
|
||||
}
|
||||
return psbt;
|
||||
}
|
||||
function inputToPsbtInput(u) {
|
||||
const input = {
|
||||
hash: u.txid,
|
||||
index: u.vout,
|
||||
};
|
||||
if (u.witness) {
|
||||
input.witnessUtxo = {
|
||||
script: Buffer.from(u.witness.scriptHex, 'hex'),
|
||||
value: u.witness.value,
|
||||
};
|
||||
}
|
||||
if (u.nonWitnessTxHex) {
|
||||
input.nonWitnessUtxo = Buffer.from(u.nonWitnessTxHex, 'hex');
|
||||
}
|
||||
if (u.redeemScriptHex) {
|
||||
input.redeemScript = Buffer.from(u.redeemScriptHex, 'hex');
|
||||
}
|
||||
if (u.tapInternalKeyHex) {
|
||||
input.tapInternalKey = Buffer.from(u.tapInternalKeyHex, 'hex');
|
||||
}
|
||||
return input;
|
||||
}
|
||||
// BIP69 lexicographic ordering. Improves privacy by not revealing input
|
||||
// selection order (which can hint at wallet coin-selection strategy).
|
||||
function sortInputs(inputs) {
|
||||
return [...inputs].sort((a, b) => {
|
||||
const cmp = a.txid.localeCompare(b.txid);
|
||||
return cmp !== 0 ? cmp : a.vout - b.vout;
|
||||
});
|
||||
}
|
||||
function sortOutputs(outputs) {
|
||||
return [...outputs].sort((a, b) => {
|
||||
if (a.value !== b.value)
|
||||
return a.value - b.value;
|
||||
return a.address.localeCompare(b.address);
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=build.js.map
|
||||
3
lib/dgb/psbt/index.d.ts
vendored
Normal file
3
lib/dgb/psbt/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './types.js';
|
||||
export * from './build.js';
|
||||
export * from './sign.js';
|
||||
4
lib/dgb/psbt/index.js
Normal file
4
lib/dgb/psbt/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './types.js';
|
||||
export * from './build.js';
|
||||
export * from './sign.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
1
lib/dgb/psbt/package.json
Normal file
1
lib/dgb/psbt/package.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"type":"module","main":"./index.js"}
|
||||
9
lib/dgb/psbt/sign.d.ts
vendored
Normal file
9
lib/dgb/psbt/sign.d.ts
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { Psbt } from 'bitcoinjs-lib';
|
||||
import type { ECPairInterface } from 'ecpair';
|
||||
export declare function signAllInputs(psbt: Psbt, keyPair: ECPairInterface): Psbt;
|
||||
export declare function finalizeAndExtract(psbt: Psbt): {
|
||||
hex: string;
|
||||
txid: string;
|
||||
};
|
||||
export declare function feeSats(psbt: Psbt): number;
|
||||
export declare function feeRateSatsPerByte(psbt: Psbt): number;
|
||||
25
lib/dgb/psbt/sign.js
Normal file
25
lib/dgb/psbt/sign.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Sign every input of a PSBT with the given key. Fails loudly if any
|
||||
// input can't be signed by this key (rather than silently leaving it
|
||||
// unsigned), so callers notice before broadcasting a half-signed tx.
|
||||
export function signAllInputs(psbt, keyPair) {
|
||||
psbt.signAllInputs(keyPair);
|
||||
return psbt;
|
||||
}
|
||||
// After all inputs are signed by all necessary parties, finalize and
|
||||
// extract the network-ready hex-encoded transaction.
|
||||
export function finalizeAndExtract(psbt) {
|
||||
psbt.finalizeAllInputs();
|
||||
const tx = psbt.extractTransaction();
|
||||
return { hex: tx.toHex(), txid: tx.getId() };
|
||||
}
|
||||
// Fee computed from the difference between total input value and total
|
||||
// output value. Requires all inputs to have witnessUtxo or
|
||||
// nonWitnessUtxo populated (which `buildPsbt` in this package
|
||||
// guarantees when the caller populates Utxo.value fields).
|
||||
export function feeSats(psbt) {
|
||||
return psbt.getFee();
|
||||
}
|
||||
export function feeRateSatsPerByte(psbt) {
|
||||
return psbt.getFeeRate();
|
||||
}
|
||||
//# sourceMappingURL=sign.js.map
|
||||
23
lib/dgb/psbt/types.d.ts
vendored
Normal file
23
lib/dgb/psbt/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface Utxo {
|
||||
txid: string;
|
||||
vout: number;
|
||||
value: number;
|
||||
address: string;
|
||||
scriptPubKey: string;
|
||||
witness?: {
|
||||
scriptHex: string;
|
||||
value: number;
|
||||
};
|
||||
nonWitnessTxHex?: string;
|
||||
redeemScriptHex?: string;
|
||||
tapInternalKeyHex?: string;
|
||||
}
|
||||
export interface Output {
|
||||
address: string;
|
||||
value: number;
|
||||
}
|
||||
export interface BuildParams {
|
||||
inputs: Utxo[];
|
||||
outputs: Output[];
|
||||
sortBip69?: boolean;
|
||||
}
|
||||
2
lib/dgb/psbt/types.js
Normal file
2
lib/dgb/psbt/types.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export {};
|
||||
//# sourceMappingURL=types.js.map
|
||||
143
lib/eip712.js
Normal file
143
lib/eip712.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces
|
||||
// the 32-byte digest that eth_signTypedData_v4 signs with the wallet's
|
||||
// secp256k1 key.
|
||||
//
|
||||
// Reference: https://eips.ethereum.org/EIPS/eip-712
|
||||
// Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message))
|
||||
// - domainSeparator = hashStruct("EIP712Domain", typedData.domain)
|
||||
// - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data))
|
||||
// - typeHash(type) = keccak256(encodeType(type))
|
||||
// - encodeType is the canonical string form; sub-types are appended in
|
||||
// alphabetical order once, without recursion into themselves twice.
|
||||
//
|
||||
// This is enough for every mainstream EIP-712 payload — Permit / EIP-2612,
|
||||
// OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not
|
||||
// implemented: fixed-size arrays of atomic types wider than a byte (rare
|
||||
// enough that no shipping dapp we care about uses them).
|
||||
|
||||
module.exports = function makeEip712({ keccak_256 }) {
|
||||
const enc = new TextEncoder();
|
||||
const concat = (...ps) => {
|
||||
const n = ps.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let k = 0;
|
||||
for (const p of ps) { out.set(p, k); k += p.length; }
|
||||
return out;
|
||||
};
|
||||
const hex2bytes = (h) => {
|
||||
const s = String(h).replace(/^0x/i, "");
|
||||
if (s.length % 2) throw new Error("hex: odd length");
|
||||
const out = new Uint8Array(s.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
};
|
||||
const bytesToBig = (b) => {
|
||||
let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v;
|
||||
};
|
||||
const bigToBe32 = (v, signed) => {
|
||||
let n = BigInt(v);
|
||||
if (n < 0n) {
|
||||
if (!signed) throw new Error("negative value for unsigned type");
|
||||
// two's complement to 256 bits
|
||||
n = (1n << 256n) + n;
|
||||
}
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
|
||||
return out;
|
||||
};
|
||||
|
||||
// encodeType walker — resolves the primary type + every struct it
|
||||
// transitively references, then emits "Primary(...)Sub1(...)Sub2(...)"
|
||||
// with sub-types in alphabetical order per the spec.
|
||||
function findDependencies(primaryType, types, found = new Set()) {
|
||||
if (found.has(primaryType) || !types[primaryType]) return found;
|
||||
found.add(primaryType);
|
||||
for (const f of types[primaryType]) {
|
||||
const base = f.type.replace(/\[.*\]$/, "");
|
||||
if (types[base]) findDependencies(base, types, found);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
function encodeType(primaryType, types) {
|
||||
const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort();
|
||||
const all = [primaryType, ...deps];
|
||||
return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join("");
|
||||
}
|
||||
function typeHash(primaryType, types) {
|
||||
return keccak_256(enc.encode(encodeType(primaryType, types)));
|
||||
}
|
||||
|
||||
// Encode one field value per its declared type. Struct + array types
|
||||
// hash themselves to 32 bytes; atomics land in a 32-byte slot each.
|
||||
function encodeValue(type, value, types) {
|
||||
// Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode
|
||||
// as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712.
|
||||
const arr = /^(.+)\[(\d*)\]$/.exec(type);
|
||||
if (arr) {
|
||||
const baseType = arr[1];
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
const encoded = items.map((v) => encodeValue(baseType, v, types));
|
||||
return keccak_256(concat(...encoded));
|
||||
}
|
||||
// Struct types: hashStruct recursion.
|
||||
if (types[type]) return hashStruct(type, value, types);
|
||||
// Atomic types.
|
||||
if (type === "string") return keccak_256(enc.encode(String(value ?? "")));
|
||||
if (type === "bytes") {
|
||||
const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []);
|
||||
return keccak_256(b);
|
||||
}
|
||||
if (type === "address") {
|
||||
const h = hex2bytes(String(value || "0x0").replace(/^0x/, ""));
|
||||
if (h.length !== 20) throw new Error("address must be 20 bytes");
|
||||
const out = new Uint8Array(32);
|
||||
out.set(h, 12);
|
||||
return out;
|
||||
}
|
||||
if (type === "bool") {
|
||||
const out = new Uint8Array(32);
|
||||
out[31] = value ? 1 : 0;
|
||||
return out;
|
||||
}
|
||||
// bytesN (fixed): left-aligned in a 32-byte word.
|
||||
const bytesN = /^bytes(\d+)$/.exec(type);
|
||||
if (bytesN) {
|
||||
const n = Number(bytesN[1]);
|
||||
if (n < 1 || n > 32) throw new Error("bytesN out of range");
|
||||
const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []);
|
||||
if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`);
|
||||
const out = new Uint8Array(32);
|
||||
out.set(b, 0);
|
||||
return out;
|
||||
}
|
||||
// uint* / int*: encode as 32-byte big-endian.
|
||||
const uintM = /^uint(\d*)$/.exec(type);
|
||||
if (uintM) return bigToBe32(value, false);
|
||||
const intM = /^int(\d*)$/.exec(type);
|
||||
if (intM) return bigToBe32(value, true);
|
||||
throw new Error("unsupported EIP-712 type: " + type);
|
||||
}
|
||||
|
||||
function encodeData(primaryType, data, types) {
|
||||
const fields = types[primaryType];
|
||||
if (!fields) throw new Error("unknown type: " + primaryType);
|
||||
const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types));
|
||||
return concat(...encoded);
|
||||
}
|
||||
function hashStruct(primaryType, data, types) {
|
||||
return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types)));
|
||||
}
|
||||
|
||||
// Full EIP-712 digest, ready for secp256k1.sign(digest, key).
|
||||
function digest(typedData) {
|
||||
const td = typedData && typeof typedData === "object" ? typedData : {};
|
||||
const types = td.types || {};
|
||||
if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing");
|
||||
const primary = String(td.primaryType || "");
|
||||
if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`);
|
||||
const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types);
|
||||
const messageHash = hashStruct(primary, td.message || {}, types);
|
||||
return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash));
|
||||
}
|
||||
|
||||
return { digest, encodeType, typeHash, hashStruct };
|
||||
};
|
||||
131
lib/electrum.js
Normal file
131
lib/electrum.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// Electrum (Fulcrum) JSON-RPC over WebSocket for the wallet. One live
|
||||
// connection at a time, chosen by walking the server list in order; the
|
||||
// caller gets a stable `call()` that reconnects transparently on the next
|
||||
// request after a drop. Notifications (headers / scripthash subscriptions)
|
||||
// fan out to `onNotify`.
|
||||
module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
|
||||
const CALL_TIMEOUT_MS = 20000;
|
||||
|
||||
class Connection {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.id = 0;
|
||||
this.pending = new Map();
|
||||
this.buf = "";
|
||||
this.closed = false;
|
||||
this.onNotify = null;
|
||||
this.onClose = null;
|
||||
}
|
||||
connect() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(this.url);
|
||||
this.ws = ws;
|
||||
const fail = (e) => { if (!this.closed) { this.closed = true; reject(e instanceof Error ? e : new Error("electrum ws error: " + this.url)); } };
|
||||
ws.on("open", async () => {
|
||||
try { await this.call("server.version", ["theseus-bchwallet", "1.4"]); resolve(this); }
|
||||
catch (e) { fail(e); this.close(); }
|
||||
});
|
||||
ws.on("error", fail);
|
||||
ws.on("message", (d) => this._onData(String(d)));
|
||||
ws.on("close", () => {
|
||||
this.closed = true;
|
||||
for (const p of this.pending.values()) p.reject(new Error("electrum connection closed"));
|
||||
this.pending.clear();
|
||||
if (this.onClose) this.onClose();
|
||||
});
|
||||
});
|
||||
}
|
||||
_onData(chunk) {
|
||||
this.buf += chunk;
|
||||
let nl;
|
||||
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
||||
const line = this.buf.slice(0, nl).trim();
|
||||
this.buf = this.buf.slice(nl + 1);
|
||||
if (line) this._handleLine(line);
|
||||
}
|
||||
const rest = this.buf.trim();
|
||||
if (rest) { try { JSON.parse(rest); this._handleLine(rest); this.buf = ""; } catch {} }
|
||||
}
|
||||
_handleLine(line) {
|
||||
let msg;
|
||||
try { msg = JSON.parse(line); } catch { return; }
|
||||
if (msg.id != null && this.pending.has(msg.id)) {
|
||||
const p = this.pending.get(msg.id);
|
||||
this.pending.delete(msg.id);
|
||||
clearTimeout(p.timer);
|
||||
if (msg.error) p.reject(new Error(typeof msg.error === "object" ? (msg.error.message || JSON.stringify(msg.error)) : String(msg.error)));
|
||||
else p.resolve(msg.result);
|
||||
} else if (msg.method && this.onNotify) {
|
||||
this.onNotify(msg.method, msg.params || []);
|
||||
}
|
||||
}
|
||||
call(method, params = []) {
|
||||
if (this.closed) return Promise.reject(new Error("electrum connection closed"));
|
||||
const id = ++this.id;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); }
|
||||
}, CALL_TIMEOUT_MS);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
try { this.ws.send(JSON.stringify({ id, method, params }) + "\n"); }
|
||||
catch (e) { clearTimeout(timer); this.pending.delete(id); reject(e); }
|
||||
});
|
||||
}
|
||||
close() { this.closed = true; try { this.ws.close(); } catch {} }
|
||||
}
|
||||
|
||||
class Client {
|
||||
constructor(servers) {
|
||||
this.servers = servers.slice();
|
||||
this.conn = null;
|
||||
this.connecting = null;
|
||||
this.subscriptions = new Map(); // method+key -> params (replayed on reconnect)
|
||||
this.onNotify = null;
|
||||
this.onServer = null; // (url|null) connection state for the UI
|
||||
}
|
||||
setServers(servers) {
|
||||
this.servers = servers.slice();
|
||||
this.disconnect();
|
||||
}
|
||||
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
|
||||
async _ensure() {
|
||||
if (this.conn && !this.conn.closed) return this.conn;
|
||||
if (this.connecting) return this.connecting;
|
||||
this.connecting = (async () => {
|
||||
let lastErr;
|
||||
for (const url of this.servers) {
|
||||
try {
|
||||
const c = await new Connection(url).connect();
|
||||
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); } };
|
||||
this.conn = c;
|
||||
log("connected", url);
|
||||
if (this.onServer) this.onServer(url);
|
||||
// Re-arm subscriptions so a reconnect keeps the live feed.
|
||||
for (const params of this.subscriptions.values()) c.call(params[0], params[1]).catch(() => {});
|
||||
return c;
|
||||
} catch (e) { lastErr = e; log("failed", url, e?.message); }
|
||||
}
|
||||
throw lastErr || new Error("no electrum server reachable");
|
||||
})();
|
||||
try { return await this.connecting; }
|
||||
finally { this.connecting = null; }
|
||||
}
|
||||
async call(method, params = []) {
|
||||
const c = await this._ensure();
|
||||
return c.call(method, params);
|
||||
}
|
||||
// Remember a subscription so it survives reconnects.
|
||||
async subscribe(method, params = []) {
|
||||
this.subscriptions.set(method + ":" + JSON.stringify(params), [method, params]);
|
||||
return this.call(method, params);
|
||||
}
|
||||
clearSubscriptions() { this.subscriptions.clear(); }
|
||||
disconnect() {
|
||||
if (this.conn) { const c = this.conn; this.conn = null; c.close(); }
|
||||
if (this.onServer) this.onServer(null);
|
||||
}
|
||||
}
|
||||
|
||||
return { Client };
|
||||
};
|
||||
66
lib/keys.js
Normal file
66
lib/keys.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// HD key tree for the wallet. Root = 32 bytes from api.vault.derive treated as
|
||||
// a BIP32 master seed; account = m/44'/145'/0' (BCH, SLIP-44). Branch 0 is
|
||||
// receive, branch 1 is change. Private keys never leave this module except
|
||||
// through sign() / signRecoverable() for a specific entry.
|
||||
module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashaddr }) {
|
||||
const hash160 = (b) => ripemd160(sha256(b));
|
||||
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
|
||||
const p2shScript = (h160) => Uint8Array.from([0xa9, 0x14, ...h160, 0x87]);
|
||||
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
// electrum scripthash: sha256(script), byte-reversed, hex.
|
||||
const scripthash = (script) => toHex(sha256(script).slice().reverse());
|
||||
|
||||
class WalletKeys {
|
||||
constructor(root32, accountPath, prefix) {
|
||||
this.prefix = prefix;
|
||||
this.accountPath = accountPath;
|
||||
this._account = HDKey.fromMasterSeed(root32).derive(accountPath);
|
||||
this._branch = [this._account.deriveChild(0), this._account.deriveChild(1)];
|
||||
this._cache = new Map(); // "branch/index" -> entry
|
||||
}
|
||||
get xpub() { return this._account.publicExtendedKey; }
|
||||
// Revealed only on explicit user action in Settings (show recovery info).
|
||||
get xprv() { return this._account.privateExtendedKey; }
|
||||
entry(branch, index) {
|
||||
const k = branch + "/" + index;
|
||||
let e = this._cache.get(k);
|
||||
if (!e) {
|
||||
const node = this._branch[branch].deriveChild(index);
|
||||
const h160 = hash160(node.publicKey);
|
||||
const script = p2pkhScript(h160);
|
||||
e = {
|
||||
branch, index, path: this.accountPath + "/" + branch + "/" + index,
|
||||
publicKey: node.publicKey, h160, script, scriptHex: toHex(script),
|
||||
scripthash: scripthash(script),
|
||||
address: cashaddr.encode(this.prefix, 0, h160),
|
||||
_node: node,
|
||||
};
|
||||
this._cache.set(k, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
findByScriptHex(scriptHex) {
|
||||
for (const e of this._cache.values()) if (e.scriptHex === scriptHex) return e;
|
||||
return null;
|
||||
}
|
||||
// ECDSA over a 32-byte digest, DER-encoded, low-S (BCH consensus rule).
|
||||
sign(entry, digest32) {
|
||||
return secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "der" });
|
||||
}
|
||||
// 65-byte BIP-137 signature: [27 + recid + 4 (compressed)] || r || s.
|
||||
signRecoverable(entry, digest32) {
|
||||
const sig = secp256k1.sign(digest32, entry._node.privateKey, { prehash: false, lowS: true, format: "recovered" });
|
||||
const out = new Uint8Array(65);
|
||||
out[0] = 27 + sig[0] + 4;
|
||||
out.set(sig.subarray(1), 1);
|
||||
return out;
|
||||
}
|
||||
wipe() {
|
||||
for (const e of this._cache.values()) { try { e._node.wipePrivateData(); } catch {} }
|
||||
this._cache.clear();
|
||||
for (const b of this._branch) { try { b.wipePrivateData(); } catch {} }
|
||||
try { this._account.wipePrivateData(); } catch {}
|
||||
}
|
||||
}
|
||||
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex };
|
||||
};
|
||||
29
lib/sia/keys.js
Normal file
29
lib/sia/keys.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Key tree for the Sia wallet: index i -> ed25519 key via walletd's
|
||||
// KeyFromSeed(root, i), address = standard unlock hash of the public key.
|
||||
// Private keys stay inside this module; sign() is the only way out.
|
||||
module.exports = function makeKeys({ sia }) {
|
||||
class WalletKeys {
|
||||
constructor(root32) {
|
||||
this._root = Uint8Array.from(root32);
|
||||
this._cache = new Map();
|
||||
}
|
||||
entry(index) {
|
||||
let e = this._cache.get(index);
|
||||
if (!e) {
|
||||
const k = sia.keyFromSeed(this._root, index);
|
||||
e = { index, pub: k.pub, address32: k.address32, address: k.address, _priv: k.priv };
|
||||
this._cache.set(index, e);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
sign(entry, msg) { return sia.sign(entry._priv, msg); }
|
||||
// Revealed only on explicit user action in Settings.
|
||||
get seedHex() { return sia.toHex(this._root); }
|
||||
wipe() {
|
||||
for (const e of this._cache.values()) e._priv.fill(0);
|
||||
this._cache.clear();
|
||||
this._root.fill(0);
|
||||
}
|
||||
}
|
||||
return { WalletKeys };
|
||||
};
|
||||
142
lib/sia/sia.js
Normal file
142
lib/sia/sia.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Sia (v2 era) primitives: the Sia binary encoder, standard unlock-hash
|
||||
// addresses, walletd's per-index key derivation, the v2 input signature hash
|
||||
// and transaction weight. Mirrors go.sia.tech/core/types; every encoding
|
||||
// here was checked against a real mainnet v2 transaction.
|
||||
module.exports = function makeSia({ ed25519, blake2b }) {
|
||||
const b256 = (data) => blake2b(data, { dkLen: 32 });
|
||||
const enc = new TextEncoder();
|
||||
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
const fromHex = (h) => Uint8Array.from(String(h).replace(/^0x/, "").match(/../g) || [], (x) => parseInt(x, 16));
|
||||
|
||||
// ---- encoder ---------------------------------------------------------------
|
||||
class Encoder {
|
||||
constructor() { this.parts = []; this.length = 0; }
|
||||
write(b) { this.parts.push(b); this.length += b.length; return this; }
|
||||
u8(n) { return this.write(Uint8Array.from([n & 0xff])); }
|
||||
bool(v) { return this.u8(v ? 1 : 0); }
|
||||
u64(n) {
|
||||
let v = BigInt(n); const out = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return this.write(out);
|
||||
}
|
||||
bytes(b) { return this.u64(b.length).write(b); }
|
||||
str(s) { return this.bytes(enc.encode(s)); }
|
||||
// Currency is a 128-bit little-endian pair (lo, hi).
|
||||
currency(hastings) {
|
||||
const v = BigInt(hastings);
|
||||
if (v < 0n || v >= (1n << 128n)) throw new Error("currency out of range");
|
||||
return this.u64(v & ((1n << 64n) - 1n)).u64(v >> 64n);
|
||||
}
|
||||
bytesOut() {
|
||||
const out = new Uint8Array(this.length); let o = 0;
|
||||
for (const p of this.parts) { out.set(p, o); o += p.length; }
|
||||
return out;
|
||||
}
|
||||
}
|
||||
const SPECIFIER_ED25519 = (() => { const s = new Uint8Array(16); s.set(enc.encode("ed25519")); return s; })();
|
||||
|
||||
// ---- addresses ---------------------------------------------------------------
|
||||
const LEAF = 0, NODE = 1;
|
||||
const sumPair = (a, b) => b256(Uint8Array.from([NODE, ...a, ...b]));
|
||||
const leaf = (bytes) => b256(Uint8Array.from([LEAF, ...bytes]));
|
||||
// Merkle root of the standard UnlockConditions {timelock 0, [pk], sigs 1}.
|
||||
function standardUnlockHash(pk) {
|
||||
const timelockHash = leaf(new Encoder().u64(0).bytesOut());
|
||||
const keyHash = leaf(new Encoder().write(SPECIFIER_ED25519).bytes(pk).bytesOut());
|
||||
const sigsHash = leaf(new Encoder().u64(1).bytesOut());
|
||||
return sumPair(sumPair(timelockHash, keyHash), sigsHash);
|
||||
}
|
||||
const addressString = (addr32) => toHex(addr32) + toHex(b256(addr32).slice(0, 6));
|
||||
function parseAddress(s) {
|
||||
const t = String(s || "").trim().toLowerCase().replace(/^addr:/, "");
|
||||
if (!/^[0-9a-f]{76}$/.test(t)) throw new Error("address must be 76 hex characters");
|
||||
const raw = fromHex(t);
|
||||
const body = raw.slice(0, 32);
|
||||
if (toHex(b256(body).slice(0, 6)) !== toHex(raw.slice(32))) throw new Error("address checksum is wrong");
|
||||
return { bytes: body, address: t };
|
||||
}
|
||||
|
||||
// ---- keys --------------------------------------------------------------------
|
||||
// walletd: key_i = ed25519 seed blake2b(seed32 || index u64le).
|
||||
function keyFromSeed(seed32, index) {
|
||||
const priv = b256(new Encoder().write(seed32).u64(index).bytesOut());
|
||||
const pub = ed25519.getPublicKey(priv);
|
||||
return { priv, pub, address32: standardUnlockHash(pub), address: addressString(standardUnlockHash(pub)) };
|
||||
}
|
||||
const sign = (priv, msg) => ed25519.sign(msg, priv);
|
||||
const verify = (pub, msg, sig) => ed25519.verify(sig, msg, pub);
|
||||
|
||||
// ---- v2 transactions ---------------------------------------------------------
|
||||
// tx: { inputs: [{ parentId(hex) }], outputs: [{ value(BigInt), address32 }], minerFee(BigInt) }
|
||||
// Sig hash = blake2b("sia/sig/input|" || 0x02 || V2TransactionSemantics).
|
||||
function inputSigHash(tx) {
|
||||
const e = new Encoder().write(enc.encode("sia/sig/input|")).u8(2);
|
||||
e.u64(tx.inputs.length);
|
||||
for (const i of tx.inputs) e.write(fromHex(i.parentId));
|
||||
e.u64(tx.outputs.length);
|
||||
for (const o of tx.outputs) e.currency(o.value).write(o.address32);
|
||||
e.u64(0).u64(0); // siafund inputs / outputs
|
||||
e.u64(0).u64(0).u64(0); // contracts, revisions, resolutions
|
||||
e.u64(0); // attestations
|
||||
e.bytes(new Uint8Array(0)); // arbitrary data
|
||||
e.bool(false); // new foundation address
|
||||
e.currency(tx.minerFee);
|
||||
return b256(e.bytesOut());
|
||||
}
|
||||
// Weight = length of the full V2Transaction encoding (fees are per byte).
|
||||
// Signed inputs carry the parent element with its Merkle proof, the policy
|
||||
// and one 64-byte signature.
|
||||
function weight(tx) {
|
||||
const e = new Encoder().u8(2);
|
||||
let fields = 0;
|
||||
if (tx.inputs.length) fields |= 1; if (tx.outputs.length) fields |= 2; if (tx.minerFee > 0n) fields |= 1 << 10;
|
||||
e.u64(fields);
|
||||
if (tx.inputs.length) {
|
||||
e.u64(tx.inputs.length);
|
||||
for (const i of tx.inputs) {
|
||||
e.u64(i.leafIndex || 0).u64((i.merkleProof || []).length);
|
||||
for (const p of i.merkleProof || []) e.write(fromHex(p));
|
||||
e.write(fromHex(i.parentId)).currency(i.value).write(i.address32).u64(i.maturityHeight || 0);
|
||||
// SatisfiedPolicy: version 1, op 7 (unlock conditions), uc, 1 sig, 0 preimages
|
||||
e.u8(1).u8(7).u64(0).u64(1).write(SPECIFIER_ED25519).bytes(i.pub).u64(1);
|
||||
e.u64(1).write(new Uint8Array(64)).u64(0);
|
||||
}
|
||||
}
|
||||
if (tx.outputs.length) { e.u64(tx.outputs.length); for (const o of tx.outputs) e.currency(o.value).write(o.address32); }
|
||||
if (tx.minerFee > 0n) e.currency(tx.minerFee);
|
||||
return e.length;
|
||||
}
|
||||
// walletd JSON for /api/txpool/broadcast.
|
||||
function toJson(tx, sigs) {
|
||||
return {
|
||||
siacoinInputs: tx.inputs.map((i, k) => ({
|
||||
parent: i.element,
|
||||
satisfiedPolicy: {
|
||||
policy: { type: "uc", policy: { timelock: 0, publicKeys: ["ed25519:" + toHex(i.pub)], signaturesRequired: 1 } },
|
||||
signatures: [toHex(sigs[k])],
|
||||
},
|
||||
})),
|
||||
siacoinOutputs: tx.outputs.map((o) => ({ value: o.value.toString(), address: addressString(o.address32) })),
|
||||
minerFee: tx.minerFee.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- units -------------------------------------------------------------------
|
||||
const HASTINGS_PER_SC = 10n ** 24n;
|
||||
function formatSC(hastings, decimals = 6) {
|
||||
const v = BigInt(hastings); const neg = v < 0n; const a = neg ? -v : v;
|
||||
const whole = a / HASTINGS_PER_SC;
|
||||
let frac = (a % HASTINGS_PER_SC).toString().padStart(24, "0").slice(0, decimals).replace(/0+$/, "");
|
||||
if (frac.length < 2) frac = frac.padEnd(2, "0");
|
||||
return (neg ? "-" : "") + whole.toString() + "." + frac;
|
||||
}
|
||||
function parseSC(text) {
|
||||
const s = String(text || "").trim().replace(/,/g, "");
|
||||
if (!/^\d*(\.\d*)?$/.test(s) || s === "" || s === ".") throw new Error("amount must be a number");
|
||||
const [w = "0", f = ""] = s.split(".");
|
||||
if (f.length > 24) throw new Error("too many decimals");
|
||||
return BigInt(w || "0") * HASTINGS_PER_SC + BigInt((f + "0".repeat(24)).slice(0, 24));
|
||||
}
|
||||
|
||||
return { Encoder, standardUnlockHash, addressString, parseAddress, keyFromSeed, sign, verify, inputSigHash, weight, toJson, formatSC, parseSC, HASTINGS_PER_SC, toHex, fromHex, b256 };
|
||||
};
|
||||
201
lib/sia/wallet.js
Normal file
201
lib/sia/wallet.js
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
// Sia wallet state: address discovery, balance, history and v2 sends over a
|
||||
// walletd client. Keys are the addon's derived key tree; nothing here touches
|
||||
// UI or IPC. Amounts are BigInt hastings throughout.
|
||||
module.exports = function makeWallet({ client, keys, sia, storage, log = () => {}, onChange = () => {} }) {
|
||||
const GAP = 10;
|
||||
const HISTORY_LIMIT = 25;
|
||||
const state = {
|
||||
used: new Set(), // indexes with any event
|
||||
height: 0,
|
||||
balance: { confirmed: 0n, immature: 0n },
|
||||
outputs: [], // spendable SiacoinElements with { index }
|
||||
basis: null,
|
||||
history: [],
|
||||
receiveIndex: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
feePerByte: 0n,
|
||||
};
|
||||
// Outputs we just spent stay hidden until walletd stops listing them.
|
||||
const pendingSpent = new Map(); // id -> timestamp
|
||||
|
||||
async function isUsed(entry) {
|
||||
const ev = await client.events(entry.address, 1, 0);
|
||||
return Array.isArray(ev) && ev.length > 0;
|
||||
}
|
||||
async function scan() {
|
||||
const cursor = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
let gap = 0, i = 0;
|
||||
while (gap < GAP || i < cursor + GAP) {
|
||||
const e = keys.entry(i);
|
||||
if (await isUsed(e)) { state.used.add(i); gap = 0; } else gap++;
|
||||
i++;
|
||||
}
|
||||
let r = cursor;
|
||||
while (state.used.has(r)) r++;
|
||||
state.receiveIndex = r;
|
||||
}
|
||||
function watched() {
|
||||
const idx = new Set(state.used); idx.add(state.receiveIndex);
|
||||
return [...idx].map((i) => keys.entry(i));
|
||||
}
|
||||
|
||||
async function loadOutputs() {
|
||||
const tip = await client.tip();
|
||||
state.height = tip.height || 0;
|
||||
let confirmed = 0n, immature = 0n; const outs = []; let basis = null;
|
||||
for (const e of watched()) {
|
||||
for (let offset = 0; ; offset += 100) {
|
||||
const r = await client.outputs(e.address, 100, offset);
|
||||
basis = r.basis || basis;
|
||||
const list = Array.isArray(r.outputs) ? r.outputs : [];
|
||||
for (const o of list) {
|
||||
const v = BigInt(o.siacoinOutput.value);
|
||||
if (o.maturityHeight > state.height) { immature += v; continue; }
|
||||
if (pendingSpent.has(o.id)) continue;
|
||||
confirmed += v;
|
||||
outs.push({ element: o, id: o.id, value: v, entry: e });
|
||||
}
|
||||
if (list.length < 100) break;
|
||||
}
|
||||
}
|
||||
for (const [id, t] of pendingSpent) if (Date.now() - t > 20 * 60 * 1000) pendingSpent.delete(id);
|
||||
state.outputs = outs; state.basis = basis;
|
||||
state.balance = { confirmed, immature };
|
||||
try { state.feePerByte = await client.feePerByte(); } catch (e) { log("fee lookup failed:", e?.message); }
|
||||
}
|
||||
|
||||
// One row per event: net change for our addresses, type, confirmations.
|
||||
async function loadHistory() {
|
||||
const ours = new Set(watched().map((e) => e.address));
|
||||
const seen = new Map();
|
||||
for (const e of watched()) {
|
||||
if (!state.used.has(e.index)) continue;
|
||||
const evs = await client.events(e.address, HISTORY_LIMIT, 0);
|
||||
for (const ev of Array.isArray(evs) ? evs : []) if (!seen.has(ev.id)) seen.set(ev.id, ev);
|
||||
}
|
||||
const rows = [];
|
||||
for (const ev of seen.values()) {
|
||||
let received = 0n, spent = 0n, to = null;
|
||||
const d = ev.data || {};
|
||||
const type = String(ev.type || "").toLowerCase();
|
||||
if (type === "v2transaction" && d.transaction) {
|
||||
for (const i of d.transaction.siacoinInputs || []) if (ours.has(i.parent.siacoinOutput.address)) spent += BigInt(i.parent.siacoinOutput.value);
|
||||
for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; }
|
||||
} else if (type === "v1transaction" && d.transaction) {
|
||||
for (const s of d.spentSiacoinElements || []) if (ours.has(s.siacoinOutput.address)) spent += BigInt(s.siacoinOutput.value);
|
||||
for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; }
|
||||
} else if (d.siacoinElement) {
|
||||
if (ours.has(d.siacoinElement.siacoinOutput.address)) received += BigInt(d.siacoinElement.siacoinOutput.value);
|
||||
}
|
||||
rows.push({
|
||||
id: ev.id, type: ev.type, height: ev.index ? ev.index.height : 0, confirmations: ev.confirmations || 0,
|
||||
time: ev.timestamp ? Math.floor(Date.parse(ev.timestamp) / 1000) : 0,
|
||||
delta: (received - spent).toString(), to: spent > received ? to : null,
|
||||
maturityHeight: ev.maturityHeight || 0,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => (b.height || Infinity) - (a.height || Infinity) || b.time - a.time);
|
||||
state.history = rows.slice(0, HISTORY_LIMIT);
|
||||
}
|
||||
|
||||
async function refresh(full = false) {
|
||||
if (state.scanning) return;
|
||||
state.scanning = true; state.error = null; onChange();
|
||||
try {
|
||||
if (full || !state.used.size && state.receiveIndex === 0) await scan();
|
||||
else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has(r)) r++; state.receiveIndex = r; }
|
||||
await loadOutputs();
|
||||
await loadHistory();
|
||||
for (const o of state.outputs) state.used.add(o.entry.index);
|
||||
let r = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
while (state.used.has(r)) r++;
|
||||
state.receiveIndex = r;
|
||||
} catch (e) {
|
||||
state.error = e?.message || String(e);
|
||||
log("refresh failed:", state.error);
|
||||
} finally { state.scanning = false; onChange(); }
|
||||
}
|
||||
let pollTimer = null;
|
||||
function startPolling(ms = 60000) { stopPolling(); pollTimer = setInterval(() => refresh(false), ms); }
|
||||
function stopPolling() { clearInterval(pollTimer); pollTimer = null; }
|
||||
|
||||
function current() { return keys.entry(state.receiveIndex); }
|
||||
function nextUnusedAddress() {
|
||||
let r = state.receiveIndex + 1;
|
||||
while (state.used.has(r)) r++;
|
||||
storage.set("receiveCursor", r);
|
||||
state.receiveIndex = r;
|
||||
onChange();
|
||||
return current();
|
||||
}
|
||||
|
||||
// targets: [{ to, value: BigInt }]; feeMultiplier 1-3 over walletd's rate.
|
||||
function plan({ targets, feeMultiplier = 1, sendMax = false }) {
|
||||
const mult = BigInt(Math.min(3, Math.max(1, Math.round(Number(feeMultiplier) || 1))));
|
||||
const rate = state.feePerByte > 0n ? state.feePerByte * mult : 10n ** 19n * mult;
|
||||
const outs = targets.map((t) => {
|
||||
const a = sia.parseAddress(t.to);
|
||||
return { value: BigInt(t.value || 0), address32: a.bytes, to: a.address };
|
||||
});
|
||||
const sorted = state.outputs.slice().sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0));
|
||||
const total = sorted.reduce((a, o) => a + o.value, 0n);
|
||||
const change = current();
|
||||
const txOf = (inputs, outputs, fee) => ({
|
||||
inputs: inputs.map((o) => ({
|
||||
parentId: o.id, element: o.element, value: o.value, address32: o.entry.address32, pub: o.entry.pub,
|
||||
leafIndex: o.element.stateElement.leafIndex, merkleProof: o.element.stateElement.merkleProof || [], maturityHeight: o.element.maturityHeight || 0, entry: o.entry,
|
||||
})),
|
||||
outputs, minerFee: fee,
|
||||
});
|
||||
if (sendMax) {
|
||||
if (outs.length !== 1) throw new Error("send max needs exactly one recipient");
|
||||
if (!sorted.length) throw new Error("no spendable balance");
|
||||
let fee = 0n;
|
||||
for (let k = 0; k < 3; k++) fee = rate * BigInt(sia.weight(txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee)));
|
||||
if (total <= fee) throw new Error("balance does not cover the fee");
|
||||
const tx = txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee);
|
||||
return { tx, fee, recipients: [{ to: outs[0].to, value: (total - fee).toString() }], change: 0n, feePerByte: rate };
|
||||
}
|
||||
const want = outs.reduce((a, o) => a + o.value, 0n);
|
||||
for (const o of outs) if (o.value <= 0n) throw new Error("amount must be positive");
|
||||
const chosen = []; let sum = 0n;
|
||||
for (const o of sorted) {
|
||||
chosen.push(o); sum += o.value;
|
||||
const withChange = [...outs, { value: 0n, address32: change.address32 }];
|
||||
const fee = rate * BigInt(sia.weight(txOf(chosen, withChange, 1n)));
|
||||
if (sum >= want + fee) {
|
||||
const rest = sum - want - fee;
|
||||
const outputs = rest > 0n ? [...outs, { value: rest, address32: change.address32 }] : outs.slice();
|
||||
const tx = txOf(chosen, outputs, fee);
|
||||
return { tx, fee, recipients: outs.map((o) => ({ to: o.to, value: o.value.toString() })), change: rest, feePerByte: rate };
|
||||
}
|
||||
}
|
||||
throw new Error("insufficient funds");
|
||||
}
|
||||
|
||||
async function signAndBroadcast(p) {
|
||||
const h = sia.inputSigHash(p.tx);
|
||||
const sigs = p.tx.inputs.map((i) => keys.sign(i.entry, h));
|
||||
const json = sia.toJson(p.tx, sigs);
|
||||
if (!state.basis) throw new Error("no chain basis for the outputs; refresh first");
|
||||
const r = await client.broadcast(state.basis, json);
|
||||
const txid = r && r.v2transactions && r.v2transactions[0] && r.v2transactions[0].id;
|
||||
for (const i of p.tx.inputs) pendingSpent.set(i.parentId, Date.now());
|
||||
log("broadcast", txid || "(no id returned)");
|
||||
setTimeout(() => refresh(false), 3000);
|
||||
return { txid: txid || null, fee: p.fee.toString() };
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const cur = current();
|
||||
return {
|
||||
address: cur.address, addressIndex: state.receiveIndex,
|
||||
balance: { confirmed: state.balance.confirmed.toString(), immature: state.balance.immature.toString() },
|
||||
height: state.height, history: state.history, outputCount: state.outputs.length,
|
||||
feePerByte: state.feePerByte.toString(), scanning: state.scanning, error: state.error,
|
||||
};
|
||||
}
|
||||
function dispose() { stopPolling(); }
|
||||
return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, startPolling, dispose, state };
|
||||
};
|
||||
42
lib/sia/walletd.js
Normal file
42
lib/sia/walletd.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Thin client for the walletd HTTP API (go.sia.tech/walletd, index mode
|
||||
// "full"). Only address-scoped reads plus txpool fee/broadcast are used, so
|
||||
// any public or self-hosted walletd works; the URL is a user setting.
|
||||
module.exports = function makeWalletd({ log = () => {} }) {
|
||||
class Client {
|
||||
constructor(baseUrl) { this.setBase(baseUrl); }
|
||||
setBase(baseUrl) {
|
||||
const u = String(baseUrl || "").trim().replace(/\/+$/, "");
|
||||
this.base = u ? (u.endsWith("/api") ? u : u + "/api") : "";
|
||||
}
|
||||
// Everything after the host is the node's business; only the origin is
|
||||
// ever logged or shown, since hosted providers key access on the path.
|
||||
get displayUrl() { try { return new URL(this.base).origin; } catch { return this.base; } }
|
||||
async _req(method, path, body) {
|
||||
if (!this.base) throw new Error("no walletd URL configured");
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 25000);
|
||||
try {
|
||||
const res = await fetch(this.base + path, {
|
||||
method, signal: ctrl.signal,
|
||||
headers: body !== undefined ? { "content-type": "application/json" } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`walletd ${res.status}: ${text.slice(0, 200).trim()}`);
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
} finally { clearTimeout(timer); }
|
||||
}
|
||||
get(path) { return this._req("GET", path); }
|
||||
post(path, body) { return this._req("POST", path, body); }
|
||||
|
||||
tip() { return this.get("/consensus/tip"); }
|
||||
// Recommended fee in hastings per byte (JSON string).
|
||||
async feePerByte() { return BigInt(String(await this.get("/txpool/fee")).replace(/"/g, "")); }
|
||||
balance(addr) { return this.get(`/addresses/${addr}/balance`); }
|
||||
// { basis, outputs: [SiacoinElement] } — proofs are valid at `basis`.
|
||||
outputs(addr, limit = 100, offset = 0) { return this.get(`/addresses/${addr}/outputs/siacoin?limit=${limit}&offset=${offset}`); }
|
||||
events(addr, limit = 25, offset = 0) { return this.get(`/addresses/${addr}/events?limit=${limit}&offset=${offset}`); }
|
||||
broadcast(basis, v2tx) { return this.post("/txpool/broadcast", { basis, transactions: [], v2transactions: [v2tx] }); }
|
||||
}
|
||||
return { Client };
|
||||
};
|
||||
220
lib/sol-spl.js
Normal file
220
lib/sol-spl.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Solana Program Library (SPL) token primitives — PDA derivation,
|
||||
// Associated Token Account math, and the two Token-program instructions
|
||||
// this wallet needs at the bytecode level: `TransferChecked` (send SPL
|
||||
// with a decimals sanity check) and `CreateAssociatedTokenAccountIdempotent`
|
||||
// (make the receiver's token account inline, so the user doesn't have to
|
||||
// pre-create it on any address they've never seen before).
|
||||
//
|
||||
// Every account address on Solana is a 32-byte ed25519 public key. A
|
||||
// Program-Derived Address (PDA) is a 32-byte value that is NOT on the
|
||||
// ed25519 curve — the runtime uses that fact as proof that no one holds
|
||||
// its private key, so only the owning program can spend from it. To
|
||||
// derive a PDA we sha256(seeds || programId || bump || "ProgramDerivedAddress")
|
||||
// for bump = 255…0 and pick the first value that isn't a valid curve
|
||||
// point. `isOnCurve` here defers to @noble/curves/ed25519's ExtendedPoint,
|
||||
// which throws on invalid points; everything that decodes is on-curve.
|
||||
|
||||
const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress");
|
||||
|
||||
module.exports = function makeSolSpl({ ed25519, sha256, base58 }) {
|
||||
if (!ed25519 || !sha256 || !base58) throw new Error("sol-spl: missing dep");
|
||||
|
||||
// ---- constants -------------------------------------------------------
|
||||
const TOKEN_PROGRAM_ID_B58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
|
||||
const ASSOC_PROGRAM_ID_B58 = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL";
|
||||
const TOKEN_2022_PROGRAM_ID_B58 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
|
||||
const TOKEN_PROGRAM_ID = base58.decode(TOKEN_PROGRAM_ID_B58);
|
||||
const ASSOC_PROGRAM_ID = base58.decode(ASSOC_PROGRAM_ID_B58);
|
||||
const TOKEN_2022_PROGRAM_ID = base58.decode(TOKEN_2022_PROGRAM_ID_B58);
|
||||
const SYSTEM_PROGRAM_ID = new Uint8Array(32);
|
||||
|
||||
// Known-token registry — just enough to give the panel a sensible label
|
||||
// for the tokens users actually see day-to-day. Everything else falls
|
||||
// back to the mint address itself (truncated in the UI).
|
||||
const KNOWN_TOKENS = {
|
||||
mainnet: {
|
||||
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { symbol: "USDC", decimals: 6, name: "USD Coin" },
|
||||
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": { symbol: "USDT", decimals: 6, name: "Tether USD" },
|
||||
"So11111111111111111111111111111111111111112": { symbol: "wSOL", decimals: 9, name: "Wrapped SOL" },
|
||||
},
|
||||
devnet: {
|
||||
"4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { symbol: "USDC", decimals: 6, name: "USD Coin (devnet)" },
|
||||
},
|
||||
};
|
||||
|
||||
// ---- ed25519 curve check --------------------------------------------
|
||||
// A 32-byte pubkey is "on curve" if it decompresses to a valid Edwards
|
||||
// point. @noble/curves v2 exposes Point.fromBytes(bytes) (throws on
|
||||
// invalid); older versions exposed ExtendedPoint.fromHex(hex-string)
|
||||
// — try each in order.
|
||||
function isOnCurve(pubkey32) {
|
||||
const P = ed25519.Point || ed25519.ExtendedPoint;
|
||||
if (!P) return true; // no curve access — treat every value as
|
||||
// on-curve (over-conservative; PDA loop falls
|
||||
// through more than it should but never gets
|
||||
// wrong).
|
||||
try {
|
||||
if (typeof P.fromBytes === "function") { P.fromBytes(pubkey32); return true; }
|
||||
if (typeof P.fromHex === "function") {
|
||||
const hex = Array.from(pubkey32, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
P.fromHex(hex); return true;
|
||||
}
|
||||
} catch { return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
const concat = (...ps) => {
|
||||
const n = ps.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let k = 0;
|
||||
for (const p of ps) { out.set(p, k); k += p.length; }
|
||||
return out;
|
||||
};
|
||||
const u64le = (n) => {
|
||||
let v = BigInt(n); const o = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return o;
|
||||
};
|
||||
const encodeCompactU16 = (n) => {
|
||||
const out = [];
|
||||
let rem = n;
|
||||
while (true) {
|
||||
let byte = rem & 0x7f; rem >>= 7;
|
||||
if (rem === 0) { out.push(byte); break; }
|
||||
byte |= 0x80; out.push(byte);
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
};
|
||||
|
||||
// ---- PDA / ATA -------------------------------------------------------
|
||||
function findProgramAddress(seeds, programId) {
|
||||
for (let bump = 255; bump >= 0; bump--) {
|
||||
const material = concat(
|
||||
...seeds.map((s) => Uint8Array.from(s)),
|
||||
Uint8Array.from([bump]),
|
||||
programId,
|
||||
PDA_MARKER,
|
||||
);
|
||||
const candidate = sha256(material);
|
||||
if (!isOnCurve(candidate)) return { address: candidate, bump };
|
||||
}
|
||||
throw new Error("no PDA found (unreachable)");
|
||||
}
|
||||
// Standard ATA = PDA under ASSOC_PROGRAM_ID with seeds
|
||||
// [ownerPubkey, TOKEN_PROGRAM_ID, mint].
|
||||
// We match the seed layout the @solana/spl-token library uses so
|
||||
// addresses agree with any wallet or block explorer.
|
||||
function associatedTokenAddress(owner, mint, tokenProgram = TOKEN_PROGRAM_ID) {
|
||||
return findProgramAddress([owner, tokenProgram, mint], ASSOC_PROGRAM_ID).address;
|
||||
}
|
||||
|
||||
// ---- instruction encoders -------------------------------------------
|
||||
// SPL Token: TransferChecked (discriminator 12) — asserts amount+decimals
|
||||
// against the mint so a UI bug can't move 1000× the intended value.
|
||||
// accounts: [sourceATA (writable), mint (readonly), destATA (writable), owner (signer)]
|
||||
// data: [12, amount:u64_le, decimals:u8]
|
||||
function transferCheckedInstruction({ sourceATA, mint, destATA, owner, amount, decimals, tokenProgram = TOKEN_PROGRAM_ID }) {
|
||||
return {
|
||||
programId: tokenProgram,
|
||||
keys: [
|
||||
{ pubkey: sourceATA, isSigner: false, isWritable: true },
|
||||
{ pubkey: mint, isSigner: false, isWritable: false },
|
||||
{ pubkey: destATA, isSigner: false, isWritable: true },
|
||||
{ pubkey: owner, isSigner: true, isWritable: false },
|
||||
],
|
||||
data: concat(Uint8Array.from([12]), u64le(amount), Uint8Array.from([decimals])),
|
||||
};
|
||||
}
|
||||
// Associated Token Account program: CreateIdempotent (discriminator 1)
|
||||
// accounts: [payer(signer,writable), ata(writable), owner(readonly),
|
||||
// mint(readonly), systemProgram(readonly), tokenProgram(readonly)]
|
||||
// data: [1] (idempotent variant — no-op if the account exists)
|
||||
function createATAIdempotentInstruction({ payer, ata, owner, mint, tokenProgram = TOKEN_PROGRAM_ID }) {
|
||||
return {
|
||||
programId: ASSOC_PROGRAM_ID,
|
||||
keys: [
|
||||
{ pubkey: payer, isSigner: true, isWritable: true },
|
||||
{ pubkey: ata, isSigner: false, isWritable: true },
|
||||
{ pubkey: owner, isSigner: false, isWritable: false },
|
||||
{ pubkey: mint, isSigner: false, isWritable: false },
|
||||
{ pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false },
|
||||
{ pubkey: tokenProgram, isSigner: false, isWritable: false },
|
||||
],
|
||||
data: Uint8Array.from([1]),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- transaction message builder ------------------------------------
|
||||
// Assembles the raw Solana message bytes for a single-fee-payer, single-
|
||||
// signer transaction that may carry multiple instructions. Account keys
|
||||
// are sorted per Solana's account-classification rules (writable-signed,
|
||||
// readonly-signed, writable-unsigned, readonly-unsigned).
|
||||
function buildMessage({ feePayer, instructions, recentBlockhash }) {
|
||||
// 1. Collect every unique pubkey mentioned across instructions +
|
||||
// include the fee payer + include each instruction's programId.
|
||||
const keys = new Map(); // b58 → { pubkey, isSigner, isWritable }
|
||||
const upsert = (pubkey, isSigner, isWritable) => {
|
||||
const k = base58.encode(pubkey);
|
||||
const cur = keys.get(k) || { pubkey, isSigner: false, isWritable: false };
|
||||
cur.isSigner = cur.isSigner || isSigner;
|
||||
cur.isWritable = cur.isWritable || isWritable;
|
||||
keys.set(k, cur);
|
||||
};
|
||||
upsert(feePayer, true, true);
|
||||
for (const ins of instructions) {
|
||||
for (const k of ins.keys) upsert(k.pubkey, k.isSigner, k.isWritable);
|
||||
upsert(ins.programId, false, false);
|
||||
}
|
||||
// 2. Classify + sort into the four buckets.
|
||||
const bucket = { ws: [], rs: [], wu: [], ru: [] };
|
||||
for (const v of keys.values()) {
|
||||
if (v.isSigner && v.isWritable) bucket.ws.push(v);
|
||||
else if (v.isSigner) bucket.rs.push(v);
|
||||
else if (v.isWritable) bucket.wu.push(v);
|
||||
else bucket.ru.push(v);
|
||||
}
|
||||
// Fee payer MUST be at index 0 (Solana requires the first signer to
|
||||
// be writable & to pay the fee).
|
||||
const payerB58 = base58.encode(feePayer);
|
||||
bucket.ws.sort((a, b) => (base58.encode(a.pubkey) === payerB58 ? -1 : base58.encode(b.pubkey) === payerB58 ? 1 : 0));
|
||||
const ordered = [...bucket.ws, ...bucket.rs, ...bucket.wu, ...bucket.ru];
|
||||
// 3. Encode the message.
|
||||
const header = Uint8Array.from([
|
||||
bucket.ws.length + bucket.rs.length, // numRequiredSignatures
|
||||
bucket.rs.length, // numReadonlySignedAccounts
|
||||
bucket.ru.length, // numReadonlyUnsignedAccounts
|
||||
]);
|
||||
const keysSection = concat(
|
||||
encodeCompactU16(ordered.length),
|
||||
...ordered.map((v) => Uint8Array.from(v.pubkey)),
|
||||
);
|
||||
const indexOf = new Map(ordered.map((v, i) => [base58.encode(v.pubkey), i]));
|
||||
const insSection = concat(
|
||||
encodeCompactU16(instructions.length),
|
||||
...instructions.map((ins) => {
|
||||
const programIndex = indexOf.get(base58.encode(ins.programId));
|
||||
const accountBytes = Uint8Array.from(ins.keys.map((k) => indexOf.get(base58.encode(k.pubkey))));
|
||||
return concat(
|
||||
Uint8Array.from([programIndex]),
|
||||
encodeCompactU16(accountBytes.length),
|
||||
accountBytes,
|
||||
encodeCompactU16(ins.data.length),
|
||||
ins.data,
|
||||
);
|
||||
}),
|
||||
);
|
||||
return concat(header, keysSection, recentBlockhash, insSection);
|
||||
}
|
||||
|
||||
return {
|
||||
// constants
|
||||
TOKEN_PROGRAM_ID, ASSOC_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, SYSTEM_PROGRAM_ID,
|
||||
TOKEN_PROGRAM_ID_B58, ASSOC_PROGRAM_ID_B58,
|
||||
KNOWN_TOKENS,
|
||||
// helpers
|
||||
isOnCurve, findProgramAddress, associatedTokenAddress,
|
||||
// instruction encoders
|
||||
transferCheckedInstruction, createATAIdempotentInstruction,
|
||||
// tx assembly
|
||||
buildMessage,
|
||||
};
|
||||
};
|
||||
122
lib/tx.js
Normal file
122
lib/tx.js
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Transaction building for P2PKH spends on Bitcoin Cash: serialization, the
|
||||
// BIP143-style replay-protected sighash (SIGHASH_ALL | FORKID), coin
|
||||
// selection and fee estimation. Signing itself is delegated to WalletKeys so
|
||||
// private keys stay in one module.
|
||||
module.exports = function makeTx({ sha256 }) {
|
||||
const SIGHASH_ALL_FORKID = 0x41;
|
||||
const DUST = 546;
|
||||
const P2PKH_INPUT_SIZE = 149; // outpoint 36 + len 1 + sig push 74 + pubkey push 34 + sequence 4
|
||||
const P2PKH_OUTPUT_SIZE = 34;
|
||||
const OVERHEAD = 10; // version 4 + in/out counts 2 + locktime 4
|
||||
|
||||
const dsha = (b) => sha256(sha256(b));
|
||||
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||
const fromHex = (h) => Uint8Array.from(h.match(/../g) || [], (x) => parseInt(x, 16));
|
||||
const concat = (...parts) => {
|
||||
const n = parts.reduce((a, p) => a + p.length, 0);
|
||||
const out = new Uint8Array(n); let o = 0;
|
||||
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||
return out;
|
||||
};
|
||||
const u32le = (n) => Uint8Array.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]);
|
||||
const u64le = (n) => {
|
||||
let v = BigInt(n); const out = new Uint8Array(8);
|
||||
for (let i = 0; i < 8; i++) { out[i] = Number(v & 0xffn); v >>= 8n; }
|
||||
return out;
|
||||
};
|
||||
const varint = (n) => {
|
||||
if (n < 0xfd) return Uint8Array.from([n]);
|
||||
if (n <= 0xffff) return Uint8Array.from([0xfd, n & 0xff, n >> 8]);
|
||||
return concat(Uint8Array.from([0xfe]), u32le(n));
|
||||
};
|
||||
const varbytes = (b) => concat(varint(b.length), b);
|
||||
const pushdata = (b) => {
|
||||
if (b.length < 0x4c) return concat(Uint8Array.from([b.length]), b);
|
||||
return concat(Uint8Array.from([0x4c, b.length]), b);
|
||||
};
|
||||
const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
|
||||
|
||||
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE;
|
||||
const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte);
|
||||
|
||||
// inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
|
||||
// outputs: [{ value, script(Uint8Array) }]
|
||||
function serialize(tx) {
|
||||
return concat(
|
||||
u32le(tx.version ?? 2),
|
||||
varint(tx.inputs.length),
|
||||
...tx.inputs.map((i) => concat(outpoint(i), varbytes(i.unlocking || new Uint8Array(0)), u32le(i.sequence ?? 0xffffffff))),
|
||||
varint(tx.outputs.length),
|
||||
...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script))),
|
||||
u32le(tx.locktime ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
function sighash(tx, index) {
|
||||
const inp = tx.inputs[index];
|
||||
const hashPrevouts = dsha(concat(...tx.inputs.map(outpoint)));
|
||||
const hashSequence = dsha(concat(...tx.inputs.map((i) => u32le(i.sequence ?? 0xffffffff))));
|
||||
const hashOutputs = dsha(concat(...tx.outputs.map((o) => concat(u64le(o.value), varbytes(o.script)))));
|
||||
const preimage = concat(
|
||||
u32le(tx.version ?? 2),
|
||||
hashPrevouts, hashSequence,
|
||||
outpoint(inp),
|
||||
varbytes(inp.script),
|
||||
u64le(inp.value),
|
||||
u32le(inp.sequence ?? 0xffffffff),
|
||||
hashOutputs,
|
||||
u32le(tx.locktime ?? 0),
|
||||
u32le(SIGHASH_ALL_FORKID),
|
||||
);
|
||||
return dsha(preimage);
|
||||
}
|
||||
|
||||
// signer(input, index, digest) -> { sig: DER bytes, publicKey }
|
||||
function sign(tx, signer) {
|
||||
tx.inputs.forEach((inp, i) => {
|
||||
const { sig, publicKey } = signer(inp, i, sighash(tx, i));
|
||||
inp.unlocking = concat(pushdata(concat(sig, Uint8Array.from([SIGHASH_ALL_FORKID]))), pushdata(publicKey));
|
||||
});
|
||||
const raw = serialize(tx);
|
||||
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
|
||||
}
|
||||
|
||||
// Largest-first accumulation. `targets` = [{ value, script }]; returns
|
||||
// { inputs, outputs, fee, change } or throws when funds don't cover it.
|
||||
// sendMax: spend every UTXO into targets[0] and no change.
|
||||
function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) {
|
||||
const sorted = utxos.slice().sort((a, b) => b.value - a.value);
|
||||
const total = sorted.reduce((a, u) => a + u.value, 0);
|
||||
if (sendMax) {
|
||||
if (targets.length !== 1) throw new Error("send max needs exactly one recipient");
|
||||
const fee = feeFor(sorted.length, 1, satPerByte);
|
||||
const value = total - fee;
|
||||
if (!sorted.length || value < DUST) throw new Error("balance too small to send");
|
||||
return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 };
|
||||
}
|
||||
const want = targets.reduce((a, t) => a + t.value, 0);
|
||||
for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
|
||||
const chosen = []; let sum = 0;
|
||||
for (const u of sorted) {
|
||||
chosen.push(u); sum += u.value;
|
||||
const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte);
|
||||
if (sum >= want + feeWithChange) {
|
||||
const change = sum - want - feeWithChange;
|
||||
if (change >= DUST) {
|
||||
return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change };
|
||||
}
|
||||
// Change would be dust: fold it into the fee, one output fewer.
|
||||
const fee = sum - want;
|
||||
return { inputs: chosen, outputs: targets.slice(), fee, change: 0 };
|
||||
}
|
||||
const feeNoChange = feeFor(chosen.length, targets.length, satPerByte);
|
||||
if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) {
|
||||
return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 };
|
||||
}
|
||||
}
|
||||
const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total;
|
||||
throw new Error(`insufficient funds: need about ${short} more sat`);
|
||||
}
|
||||
|
||||
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha };
|
||||
};
|
||||
243
lib/wallet.js
Normal file
243
lib/wallet.js
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Wallet state machine on top of an electrum client and a WalletKeys tree:
|
||||
// address discovery (gap limit), balance, history with per-tx deltas, UTXO
|
||||
// set and send construction. Knows nothing about UI or IPC.
|
||||
module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
|
||||
const GAP = 20;
|
||||
const HISTORY_LIMIT = 25;
|
||||
const sats = (bch) => Math.round(Number(bch) * 1e8);
|
||||
|
||||
const state = {
|
||||
used: new Set(), // "branch/index" with history
|
||||
watched: new Map(), // scripthash -> entry
|
||||
height: 0,
|
||||
balance: { confirmed: 0, unconfirmed: 0 },
|
||||
utxos: [], // { txid, vout, value, height, entry }
|
||||
history: [], // newest first
|
||||
receiveIndex: 0,
|
||||
scanning: false,
|
||||
error: null,
|
||||
};
|
||||
// Verbose transactions are public chain data; caching them on disk saves a
|
||||
// round of fetches on every launch.
|
||||
const txCache = storage.get("txCache", {}) || {};
|
||||
let refreshTimer = null;
|
||||
let subscribedHeaders = false;
|
||||
|
||||
function key(e) { return e.branch + "/" + e.index; }
|
||||
function watch(e) { if (!state.watched.has(e.scripthash)) state.watched.set(e.scripthash, e); }
|
||||
|
||||
async function historyOf(e) {
|
||||
const h = await client.call("blockchain.scripthash.get_history", [e.scripthash]);
|
||||
return Array.isArray(h) ? h : [];
|
||||
}
|
||||
|
||||
// Walk both branches until GAP consecutive unused indexes, always covering
|
||||
// the user's chosen receive cursor so its lookahead stays subscribed.
|
||||
async function scan() {
|
||||
const cursor = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
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(keys.entry(branch, i + k));
|
||||
const results = await Promise.all(batch.map(historyOf));
|
||||
for (let k = 0; k < batch.length; k++) {
|
||||
const e = batch[k]; watch(e);
|
||||
if (results[k].length) { state.used.add(key(e)); gap = 0; } else gap++;
|
||||
i++;
|
||||
if (gap >= GAP && i >= minIndex + GAP) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Current receive address: first unused at or after the cursor.
|
||||
let r = cursor;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
state.receiveIndex = r;
|
||||
watch(keys.entry(0, r));
|
||||
}
|
||||
|
||||
async function subscribeAll() {
|
||||
if (!subscribedHeaders) {
|
||||
subscribedHeaders = true;
|
||||
const tip = await client.subscribe("blockchain.headers.subscribe", []);
|
||||
if (tip && tip.height) state.height = tip.height;
|
||||
}
|
||||
await Promise.all([...state.watched.values()].map((e) =>
|
||||
client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {})));
|
||||
}
|
||||
|
||||
async function loadUtxos() {
|
||||
const lists = await Promise.all([...state.watched.values()].map(async (e) => {
|
||||
const u = await 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 }));
|
||||
}));
|
||||
state.utxos = lists.flat();
|
||||
let confirmed = 0, unconfirmed = 0;
|
||||
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
|
||||
state.balance = { confirmed, unconfirmed };
|
||||
}
|
||||
|
||||
async function getTx(txid) {
|
||||
const c = txCache[txid];
|
||||
if (c && c.confirmations > 0) return c;
|
||||
const raw = await client.call("blockchain.transaction.get", [txid, true]);
|
||||
const slim = {
|
||||
txid,
|
||||
confirmations: raw.confirmations || 0,
|
||||
time: raw.blocktime || raw.time || 0,
|
||||
vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })),
|
||||
vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })),
|
||||
size: raw.size || 0,
|
||||
};
|
||||
txCache[txid] = slim;
|
||||
return slim;
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
const entries = [...state.watched.values()].filter((e) => state.used.has(key(e)));
|
||||
const merged = new Map();
|
||||
const lists = await Promise.all(entries.map(historyOf));
|
||||
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, HISTORY_LIMIT);
|
||||
const ours = new Set([...state.watched.values()].map((e) => e.scriptHex));
|
||||
const out = [];
|
||||
for (const h of ordered) {
|
||||
const t = await getTx(h.txid);
|
||||
let received = 0, spent = 0, inputsTotal = 0, outputsTotal = 0, allInputsOurs = true;
|
||||
for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; }
|
||||
for (const i of t.vin) {
|
||||
if (!i.txid) continue; // coinbase
|
||||
const p = await getTx(i.txid);
|
||||
const po = p.vout[i.vout];
|
||||
if (!po) continue;
|
||||
inputsTotal += po.value;
|
||||
if (ours.has(po.scriptHex)) spent += po.value; else allInputsOurs = false;
|
||||
}
|
||||
const delta = received - spent;
|
||||
let to = null;
|
||||
if (delta < 0) {
|
||||
const ext = t.vout.find((o) => !ours.has(o.scriptHex));
|
||||
if (ext && ext.scriptHex) to = scriptToAddress(ext.scriptHex);
|
||||
}
|
||||
out.push({
|
||||
txid: t.txid, height: h.height, confirmations: t.confirmations, time: t.time,
|
||||
delta, fee: allInputsOurs && inputsTotal ? inputsTotal - outputsTotal : null, to,
|
||||
});
|
||||
}
|
||||
state.history = out;
|
||||
storage.set("txCache", txCache);
|
||||
}
|
||||
|
||||
function scriptToAddress(scriptHex) {
|
||||
try {
|
||||
if (/^76a914[0-9a-f]{40}88ac$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 0, tx.fromHex(scriptHex.slice(6, 46)));
|
||||
if (/^a914[0-9a-f]{40}87$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 1, tx.fromHex(scriptHex.slice(4, 44)));
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refresh(full = false) {
|
||||
if (state.scanning) return;
|
||||
state.scanning = true; state.error = null; onChange();
|
||||
try {
|
||||
if (full || !state.watched.size) await scan();
|
||||
else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has("0/" + r)) r++; state.receiveIndex = r; watch(keys.entry(0, r)); }
|
||||
await loadUtxos();
|
||||
await loadHistory();
|
||||
await subscribeAll();
|
||||
// A tx that just landed can mark the current receive address used.
|
||||
for (const u of state.utxos) state.used.add(key(u.entry));
|
||||
let r = Number(storage.get("receiveCursor", 0)) || 0;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
if (r !== state.receiveIndex) { state.receiveIndex = r; watch(keys.entry(0, r)); }
|
||||
} catch (e) {
|
||||
state.error = e?.message || String(e);
|
||||
log("refresh failed:", state.error);
|
||||
} finally {
|
||||
state.scanning = false;
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
function scheduleRefresh(ms = 800) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = setTimeout(() => refresh(false), ms);
|
||||
}
|
||||
|
||||
client.onNotify = (method, params) => {
|
||||
if (method === "blockchain.headers.subscribe") {
|
||||
const h = params && params[0] && params[0].height;
|
||||
if (h) { state.height = h; scheduleRefresh(1500); }
|
||||
} else if (method === "blockchain.scripthash.subscribe") {
|
||||
scheduleRefresh(800);
|
||||
}
|
||||
};
|
||||
|
||||
function nextUnusedAddress() {
|
||||
let r = state.receiveIndex + 1;
|
||||
while (state.used.has("0/" + r)) r++;
|
||||
storage.set("receiveCursor", r);
|
||||
state.receiveIndex = r;
|
||||
watch(keys.entry(0, r));
|
||||
client.subscribe("blockchain.scripthash.subscribe", [keys.entry(0, r).scripthash]).catch(() => {});
|
||||
onChange();
|
||||
return current();
|
||||
}
|
||||
function current() { return keys.entry(0, state.receiveIndex); }
|
||||
function changeEntry() {
|
||||
let i = 0;
|
||||
while (state.used.has("1/" + i)) i++;
|
||||
return keys.entry(1, i);
|
||||
}
|
||||
|
||||
// targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan.
|
||||
function plan({ targets, feeRate = 1, sendMax = false }) {
|
||||
const rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
|
||||
const outs = targets.map((t) => {
|
||||
const a = cashaddr.parseAny(t.to, sha256, keys.prefix);
|
||||
const script = a.type === 0
|
||||
? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac])
|
||||
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
|
||||
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
|
||||
});
|
||||
// Spend confirmed coins first; unconfirmed only when needed.
|
||||
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
|
||||
const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax });
|
||||
return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) };
|
||||
}
|
||||
|
||||
async function signAndBroadcast(p) {
|
||||
const t = { inputs: p.inputs.map((u) => ({ ...u, script: u.entry.script })), outputs: p.outputs };
|
||||
const signed = tx.sign(t, (inp, _i, digest) => ({ sig: keys.sign(inp.entry, digest), publicKey: inp.entry.publicKey }));
|
||||
const txid = await client.call("blockchain.transaction.broadcast", [signed.hex]);
|
||||
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
|
||||
log("broadcast", txid);
|
||||
scheduleRefresh(1200);
|
||||
return { txid, hex: signed.hex, fee: p.fee };
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const cur = current();
|
||||
return {
|
||||
address: cur.address,
|
||||
addressIndex: state.receiveIndex,
|
||||
addressPath: cur.path,
|
||||
balance: state.balance,
|
||||
height: state.height,
|
||||
history: state.history,
|
||||
utxoCount: state.utxos.length,
|
||||
scanning: state.scanning,
|
||||
error: state.error,
|
||||
};
|
||||
}
|
||||
|
||||
function dispose() { clearTimeout(refreshTimer); }
|
||||
|
||||
return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, dispose, state };
|
||||
};
|
||||
374
panel.html
Normal file
374
panel.html
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Aegis Wallet</title>
|
||||
<!-- Aegis brand mark from aegis.x/brand/favicon.svg — kept byte-close so
|
||||
the sidebar-tab icon reads as the identity a user just downloaded from. -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,%3Csvg%20xmlns%3D'http%3A//www.w3.org/2000/svg'%20viewBox%3D'0%200%2032%2032'%20fill%3D'none'%3E%3Cpolygon%20points%3D'16%2C2%2028%2C9%2028%2C23%2016%2C30%204%2C23%204%2C9'%20fill%3D'%230a0a0d'%20stroke%3D'%23D6FF3D'%20stroke-width%3D'1.6'%20stroke-linejoin%3D'round'/%3E%3Ccircle%20cx%3D'16'%20cy%3D'16'%20r%3D'4.5'%20fill%3D'none'%20stroke%3D'%23D6FF3D'%20stroke-width%3D'1.4'/%3E%3Ccircle%20cx%3D'16'%20cy%3D'16'%20r%3D'1.6'%20fill%3D'%23D6FF3D'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root { color-scheme: light dark;
|
||||
--bg:#0e131c; --panel:#141a24; --card:#0f1621; --line:rgba(255,255,255,.09);
|
||||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#f6768a; --ok:#5ad38a;
|
||||
--bch:#0ac18e; --trx:#ff060a; --trxNile:#4d9dff; }
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg:#f8faff; --panel:#ffffff; --card:#f1f4fa; --line:rgba(0,0,0,.10);
|
||||
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
|
||||
/* Darker acid on light backgrounds — ~7:1 contrast on white. */
|
||||
--acid: #0AC18E; }
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); color: var(--ink);
|
||||
font: 13.5px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; }
|
||||
header { padding: 10px 14px 10px; border-bottom: 1px solid var(--line); background: var(--panel); position: relative; }
|
||||
.picker { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; user-select: none; }
|
||||
.picker .t { display: flex; align-items: center; gap: 7px; font-weight: 600; min-width: 0; }
|
||||
.picker .t .badge { font-size: 15px; line-height: 1; }
|
||||
.picker .t .lbl { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.picker .caret { color: var(--mut); font-size: 10px; }
|
||||
.picker:hover { color: var(--acid); }
|
||||
.net { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 11.5px; white-space: nowrap; }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dim); }
|
||||
.dot.on { background: var(--ok); } .dot.busy { background: #e0b341; }
|
||||
.bal { margin-top: 8px; font-variant-numeric: tabular-nums; }
|
||||
.bal .big { font-size: 22px; font-weight: 650; letter-spacing: .2px; }
|
||||
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
|
||||
.bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
|
||||
.bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#drop { position: absolute; left: 10px; right: 10px; top: 100%; background: var(--panel); border: 1px solid var(--line);
|
||||
border-radius: 10px; box-shadow: 0 8px 26px rgba(0,0,0,.3); z-index: 20; padding: 4px; margin-top: 4px; max-height: 60vh; overflow-y: auto; }
|
||||
#drop[hidden] { display: none; }
|
||||
#drop .row, #drop .coinrow { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: 7px; cursor: pointer; }
|
||||
#drop .row:hover, #drop .coinrow:hover { background: rgba(255,255,255,.05); }
|
||||
#drop .row .m, #drop .coinrow .m { flex: 1; min-width: 0; }
|
||||
#drop .row .m .l, #drop .coinrow .m .l { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#drop .row .m .s, #drop .coinrow .m .s { color: var(--dim); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#drop .row .v { color: var(--mut); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
|
||||
#drop .row.on { background: rgb(from var(--acid) r g b / .10); }
|
||||
#drop .coinrow .caret { color: var(--dim); font-size: 11px; }
|
||||
#drop hr { border: 0; border-top: 1px solid var(--line); margin: 6px 0; }
|
||||
#drop .addhdr { padding: 5px 8px 3px; color: var(--dim); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
#drop .netgroup { padding: 2px 4px 6px 40px; }
|
||||
#drop .netgroup[hidden] { display: none; }
|
||||
#drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); }
|
||||
#drop .netchoice:hover { background: rgba(255,255,255,.06); color: var(--ink); }
|
||||
.ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px;
|
||||
background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; }
|
||||
#hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
|
||||
.picker-actions { display: flex; align-items: center; gap: 6px; }
|
||||
.chip { background: transparent; border: 1px solid var(--line); color: var(--mut); border-radius: 6px;
|
||||
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1;
|
||||
display: inline-flex; align-items: center; justify-content: center; }
|
||||
.chip:hover { border-color: var(--acid); color: var(--acid); }
|
||||
nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); }
|
||||
nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
|
||||
nav button.on { color: var(--acid); border-bottom-color: var(--acid); }
|
||||
nav button:hover { color: var(--ink); }
|
||||
main { flex: 1; overflow: auto; padding: 14px; }
|
||||
section[hidden] { display: none; }
|
||||
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; }
|
||||
.mono { font: 12.5px/1.45 ui-monospace, "Cascadia Code", Consolas, monospace; word-break: break-all; }
|
||||
.lbl { color: var(--dim); font-size: 11.5px; margin-bottom: 4px; }
|
||||
.btn { padding: 7px 12px; border-radius: 7px; border: 1px solid var(--line); background: var(--panel); color: var(--ink);
|
||||
cursor: pointer; font: inherit; font-size: 12.5px; }
|
||||
.btn:hover { border-color: rgb(from var(--acid) r g b / .4); }
|
||||
.btn.primary { background: var(--acid); color: #0b0e14; border-color: transparent; font-weight: 650; }
|
||||
.btn.primary:disabled { opacity: .45; cursor: default; }
|
||||
.btn.danger { color: var(--danger); }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
.actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-bottom: 12px; }
|
||||
canvas { image-rendering: pixelated; }
|
||||
input[type=text], input[type=number], textarea { width: 100%; padding: 7px 9px; border-radius: 7px; background: var(--panel);
|
||||
border: 1px solid var(--line); color: var(--ink); font: inherit; font-size: 13px; outline: none; }
|
||||
input:focus, textarea:focus { border-color: rgb(from var(--acid) r g b / .5); }
|
||||
textarea { resize: vertical; min-height: 96px; font: 12px/1.45 ui-monospace, Consolas, monospace; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.hint { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
|
||||
.amt { display: flex; gap: 6px; }
|
||||
.amt input { flex: 1; }
|
||||
.unit { display: flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; }
|
||||
.unit button { border: 0; background: var(--panel); color: var(--mut); padding: 0 10px; cursor: pointer; font: inherit; font-size: 12px; }
|
||||
.unit button.on { background: rgb(from var(--acid) r g b / .14); color: var(--acid); }
|
||||
.fee { display: flex; align-items: center; gap: 10px; }
|
||||
.fee input[type=range] { flex: 1; accent-color: var(--acid); }
|
||||
.fee .v { color: var(--mut); font-size: 12px; min-width: 70px; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.summary { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; font-size: 12.5px; margin-top: 10px; }
|
||||
.summary .k { color: var(--dim); } .summary .v { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.msg { margin-top: 10px; font-size: 12.5px; padding: 8px 10px; border-radius: 7px; }
|
||||
.msg.err { background: rgba(246,118,138,.12); color: var(--danger); }
|
||||
.msg.ok { background: rgba(90,211,138,.12); color: var(--ok); }
|
||||
.msg a { color: inherit; }
|
||||
.tx { display: grid; grid-template-columns: 22px 1fr auto; gap: 2px 10px; padding: 9px 4px; border-bottom: 1px solid var(--line); cursor: pointer; }
|
||||
.tx:hover { background: rgba(255,255,255,.03); }
|
||||
.tx .ic { font-size: 15px; line-height: 1.3; }
|
||||
.tx .ic.in { color: var(--ok); } .tx .ic.out { color: var(--danger); }
|
||||
.tx .what { font-size: 12.5px; }
|
||||
.tx .when { color: var(--dim); font-size: 11.5px; grid-column: 2; }
|
||||
.tx .amt2 { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 12.5px; }
|
||||
.tx .amt2.in { color: var(--ok); }
|
||||
.tx .conf { grid-column: 3; text-align: right; color: var(--dim); font-size: 11px; }
|
||||
.tx .conf.pending { color: #e0b341; }
|
||||
.empty { color: var(--dim); text-align: center; padding: 30px 10px; font-size: 12.5px; }
|
||||
.gate { padding: 40px 18px; text-align: center; color: var(--mut); }
|
||||
.gate .big { font-size: 28px; margin-bottom: 8px; }
|
||||
.gate b { color: var(--ink); }
|
||||
.kv { margin-top: 10px; }
|
||||
.kv .lbl { margin-top: 8px; }
|
||||
a.link { color: var(--acid); text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="picker" id="pickerBtn">
|
||||
<div class="t">
|
||||
<span class="badge" id="hBadge"></span>
|
||||
<span class="lbl" id="hLabel">Aegis Wallet</span>
|
||||
<span id="hNet"></span>
|
||||
</div>
|
||||
<div class="picker-actions">
|
||||
<button type="button" id="hAddWallet" class="chip" title="Add a new wallet">+</button>
|
||||
<span class="caret">▾</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="drop" hidden></div>
|
||||
<div class="bal">
|
||||
<div class="big"><span id="balMain">—</span><small id="balTicker">—</small></div>
|
||||
<div class="sub">
|
||||
<span class="netlbl" id="netlbl">connecting…</span>
|
||||
<span class="net"><span class="dot" id="dot"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<nav>
|
||||
<button data-tab="receive" class="on">Receive</button>
|
||||
<button data-tab="send">Send</button>
|
||||
<button data-tab="history">History</button>
|
||||
<button data-tab="settings">Settings</button>
|
||||
</nav>
|
||||
<main>
|
||||
<div id="gate" class="gate" hidden></div>
|
||||
<div id="tabs">
|
||||
<section id="tab-receive">
|
||||
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
|
||||
<div class="card">
|
||||
<div class="lbl">Receiving address <span id="addrMeta"></span></div>
|
||||
<div class="mono" id="addr">—</div>
|
||||
<div class="actions">
|
||||
<button class="btn sm" id="copyAddr">Copy</button>
|
||||
<button class="btn sm" id="nextAddr" hidden>Next unused address</button>
|
||||
<button class="btn sm" id="viewAddr">Explorer</button>
|
||||
<button class="btn sm" id="openFaucet" hidden>Faucet</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="tokensCard" hidden style="margin-top:12px">
|
||||
<div class="lbl">Tokens</div>
|
||||
<div id="tokensList" class="kv"></div>
|
||||
<div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tab-send" hidden>
|
||||
<div class="field" id="sendAssetField" hidden>
|
||||
<div class="lbl">Asset</div>
|
||||
<select id="sendAsset"></select>
|
||||
<div class="hint">Pick the native coin or an SPL token in this wallet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Recipient</div>
|
||||
<input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="…">
|
||||
<div class="hint" id="sendToHint"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Amount</div>
|
||||
<div class="amt">
|
||||
<input type="text" id="sendAmt" inputmode="decimal" autocomplete="off" placeholder="0.00">
|
||||
<div class="unit" id="unitPicker"></div>
|
||||
<button class="btn sm" id="sendMax" type="button">Max</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="feeField">
|
||||
<div class="lbl">Fee</div>
|
||||
<div class="fee"><input type="range" id="feeRate" min="1" max="5" step="1" value="1"><span class="v" id="feeLbl">1 sat/B</span></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="summary">
|
||||
<div class="k">Amount</div><div class="v" id="sumAmt">—</div>
|
||||
<div class="k">Network fee</div><div class="v" id="sumFee">—</div>
|
||||
<div class="k">Total</div><div class="v" id="sumTotal">—</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
|
||||
<div class="msg" id="sendMsg" hidden></div>
|
||||
</section>
|
||||
<section id="tab-history" hidden>
|
||||
<div id="txlist"></div>
|
||||
</section>
|
||||
<section id="tab-settings" hidden>
|
||||
<div class="card" id="walletManage" style="margin-bottom:14px">
|
||||
<div class="lbl">This wallet</div>
|
||||
<div class="field" style="margin-top:6px;margin-bottom:8px">
|
||||
<input type="text" id="renameLabel" placeholder="Wallet name">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn sm" id="renameBtn">Rename</button>
|
||||
<button class="btn sm danger" id="removeBtn">Remove wallet</button>
|
||||
</div>
|
||||
<div class="hint" id="removeHint"></div>
|
||||
</div>
|
||||
|
||||
<div id="bchSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">Derivation path (account)</div>
|
||||
<input type="text" id="setPath" spellcheck="false" placeholder="m/44'/145'/0'">
|
||||
<div class="hint">Changing this switches to a different set of addresses under the same wallet seed.</div>
|
||||
</div>
|
||||
<div class="field" id="bchServersRow">
|
||||
<div class="lbl">Electrum servers (shared across BCH mainnet wallets, one per line)</div>
|
||||
<textarea id="setServers" spellcheck="false"></textarea>
|
||||
<div class="hint" id="serverHint"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applySettings">Apply</button>
|
||||
<button class="btn" id="resetServers">Reset servers</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="purpose"></span>. The same vault seed on another machine recreates this wallet.</div>
|
||||
<div class="kv" id="recovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="showXpub">Show account xpub</button>
|
||||
<button class="btn danger" id="showXprv">Show account private key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="trxSettings" hidden>
|
||||
<div class="card">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="trxPurpose"></span>. Import into TronLink with the derivation path <span class="mono">m/44'/195'/0'/0/0</span> from the same vault seed.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="scSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">walletd URL</div>
|
||||
<input type="text" id="setWalletdUrl" spellcheck="false" placeholder="https://your-walletd.example/api">
|
||||
<div class="hint">Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode. The URL is per-wallet, so different Sia sub-accounts can point at different nodes. It is not shared with anyone else.</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applyWalletdUrl">Apply</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="scPurpose"></span>. Address scheme: walletd <span class="mono">KeyFromSeed(seed, index)</span>.</div>
|
||||
<div class="kv" id="scRecovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn danger" id="showScSeed">Reveal wallet seed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dgbSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">Address family</div>
|
||||
<select id="setDgbFamily"></select>
|
||||
<div class="hint">Picks the BIP purpose that shapes your DGB addresses. Switching rebuilds the wallet against a different set of addresses under the same seed — old funds don't move; they still live under the family they were received on.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Derivation path (account)</div>
|
||||
<input type="text" id="setDgbPath" spellcheck="false" placeholder="m/84'/20'/0'">
|
||||
<div class="hint">Auto-filled from the family above. Edit only if you need a non-default account.</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applyDgbPath">Apply</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="dgbPurpose"></span>. Any BIP39 tool set to <span class="mono">DGB</span> (coin type 20) at the same purpose can reproduce this wallet.</div>
|
||||
<div class="kv" id="dgbRecovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="showDgbXpub">Show account xpub</button>
|
||||
<button class="btn danger" id="showDgbXprv">Show account private key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="btcSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">Address family</div>
|
||||
<select id="setBtcFamily"></select>
|
||||
<div class="hint">Picks the BIP purpose that shapes your Bitcoin addresses. Switching rebuilds the wallet against a different set of addresses under the same seed — old funds don't move; they still live under the family they were received on.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Derivation path (account)</div>
|
||||
<input type="text" id="setBtcPath" spellcheck="false" placeholder="m/84'/0'/0'">
|
||||
<div class="hint">Auto-filled from the family above. Edit only if you need a non-default account.</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applyBtcPath">Apply</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="btcPurpose"></span>. Any BIP39 tool at coin type 0 (mainnet) / 1 (testnet) and the same purpose can reproduce this wallet.</div>
|
||||
<div class="kv" id="btcRecovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn" id="showBtcXpub">Show account xpub</button>
|
||||
<button class="btn danger" id="showBtcXprv">Show account private key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ethSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">RPC URL</div>
|
||||
<input type="text" id="setEthRpcUrl" spellcheck="false" placeholder="https://cloudflare-eth.com">
|
||||
<div class="hint">JSON-RPC endpoint Aegis reads balances and broadcasts from. Any provider works — public gateways (Cloudflare, PublicNode), Alchemy, Infura, or your own node. Stored per-wallet; not shared.</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applyEthRpc">Apply</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="ethPurpose"></span>. Import into MetaMask / any Ethereum wallet using the derivation path <span class="mono">m/44'/60'/0'/0/0</span> from the same vault seed.</div>
|
||||
<div class="kv" id="ethRecovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn danger" id="showEthKey">Reveal private key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="solSettings" hidden>
|
||||
<div class="field">
|
||||
<div class="lbl">RPC URL</div>
|
||||
<input type="text" id="setSolRpcUrl" spellcheck="false" placeholder="https://api.mainnet-beta.solana.com">
|
||||
<div class="hint">JSON-RPC endpoint. The public Solana RPCs are heavily rate-limited — for real use point Aegis at Helius / QuickNode / your own node.</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn primary" id="applySolRpc">Apply</button>
|
||||
</div>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Recovery info</div>
|
||||
<div class="hint">Keys come from your Theseus password vault under <span class="mono" id="solPurpose"></span>. Import into Phantom / Solflare with the derivation path <span class="mono">m/44'/501'/0'/0'</span> from the same vault seed.</div>
|
||||
<div class="kv" id="solRecovery"></div>
|
||||
<div class="actions">
|
||||
<button class="btn danger" id="showSolKey">Reveal wallet seed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="lbl">Connected sites</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>
|
||||
<div class="msg err" id="settingsMsg" hidden></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<script src="qr.js"></script>
|
||||
<script src="panel.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
836
panel.js
Normal file
836
panel.js
Normal file
|
|
@ -0,0 +1,836 @@
|
|||
// Aegis wallet panel. All state comes from activate() via window.silentmode.
|
||||
// This file renders the multi-wallet picker, per-chain views, and collects
|
||||
// input; it never touches keys or the vault.
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const S = window.silentmode;
|
||||
let state = null; // full state (all wallets + selected)
|
||||
let tab = "receive";
|
||||
let unit = null; // "big" | "small" — chain-dependent
|
||||
let sendMax = false;
|
||||
let planTimer = null;
|
||||
let lastPlan = null;
|
||||
let settingsFilled = false;
|
||||
// Selected asset for the Send tab. `null` = native coin. Otherwise a
|
||||
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
|
||||
let sendAsset = null;
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } };
|
||||
const openUrl = (url) => S.invoke("openUrl", { url }).catch(() => {});
|
||||
const cleanErr = (e) => String(e?.message || e).replace(/^Error invoking remote method '[^']+': Error: /, "");
|
||||
|
||||
// ---- coin logos ------------------------------------------------------------
|
||||
// Inline SVGs so the header, wallet picker and settings surface all render
|
||||
// the same mark. Sized by the container via width/height attributes.
|
||||
function logoSvg(logo, size) {
|
||||
const s = size || 20;
|
||||
if (logo === "bch") {
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin Cash" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#0ac18e" stroke="#0a9a72" stroke-width=".8"/>
|
||||
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="20" font-weight="800" fill="#fff">₿</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "trx") {
|
||||
// Simplified from the official geometric Tron mark: triangle + tail line.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Tron" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#ff060a" stroke="#c1050a" stroke-width=".8"/>
|
||||
<path d="M7.5 10.2 L24 12.5 L14.5 23.8 Z"
|
||||
fill="none" stroke="#fff" stroke-width="1.7" stroke-linejoin="round"/>
|
||||
<line x1="7.5" y1="10.2" x2="14.5" y2="23.8" stroke="#fff" stroke-width="1.7" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "sc") {
|
||||
// Sia's mark is a stylized S built from two mirrored crescents. Approximated
|
||||
// here with a plain S glyph on the brand green so it reads at 22px.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Siacoin" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#20be82" stroke="#158e5f" stroke-width=".8"/>
|
||||
<text x="16" y="22.3" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="800" fill="#fff">S</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "dgb") {
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="DigiByte" style="vertical-align:middle;flex:none">
|
||||
<polygon points="10,2 22,2 30,10 30,22 22,30 10,30 2,22 2,10" fill="#0066cc" stroke="#004a99" stroke-width=".8"/>
|
||||
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="18" font-weight="800" fill="#fff">D</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "btc") {
|
||||
// Orange disc with the Bitcoin sign — the widely-recognised BTC mark.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Bitcoin" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#f7931a" stroke="#c76e0a" stroke-width=".8"/>
|
||||
<text x="16" y="22.4" text-anchor="middle" font-family="Segoe UI,Arial,sans-serif" font-size="20" font-weight="800" fill="#fff">₿</text>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "eth") {
|
||||
// Ethereum's mark is the two-triangle rhombus. Simplified to the
|
||||
// silhouette on a light-purple disc so it reads at 22px.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Ethereum" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#627eea" stroke="#4058c0" stroke-width=".8"/>
|
||||
<polygon points="16,5 22,17 16,20 10,17" fill="#fff" opacity=".95"/>
|
||||
<polygon points="16,21 22,18 16,27 10,18" fill="#fff" opacity=".7"/>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "sol") {
|
||||
// Solana's three-slant mark. Purple → green gradient in the brand
|
||||
// spec; approximated with two solid parallelograms on a dark disc.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Solana" style="vertical-align:middle;flex:none">
|
||||
<circle cx="16" cy="16" r="15.5" fill="#0f0f14" stroke="#2a2a3a" stroke-width=".8"/>
|
||||
<polygon points="9,11 22,11 20,14 7,14" fill="#9945ff"/>
|
||||
<polygon points="9,15 22,15 20,18 7,18" fill="#14f195"/>
|
||||
<polygon points="9,19 22,19 20,22 7,22" fill="#00d1ff"/>
|
||||
</svg>`;
|
||||
}
|
||||
if (logo === "aegis") {
|
||||
// Athena's aspis — hexagonal shield with a boss at center + four
|
||||
// spoke marks. Same silhouette as the aegis.x hero SVG so the wallet
|
||||
// and the marketing page read as one identity.
|
||||
return `<svg viewBox="0 0 32 32" width="${s}" height="${s}" aria-label="Aegis" style="vertical-align:middle;flex:none">
|
||||
<polygon points="16,2 29,9 29,23 16,30 3,23 3,9" fill="none" stroke="#d6ff3d" stroke-width="2" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="16" r="4.3" fill="none" stroke="#d6ff3d" stroke-width="1.4"/>
|
||||
<circle cx="16" cy="16" r="1.3" fill="#d6ff3d"/>
|
||||
<path d="M16 10.5 v-2.4 M16 21.5 v2.4 M10.5 16 h-2.4 M21.5 16 h2.4" stroke="#d6ff3d" stroke-width="1.4" stroke-linecap="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
// Fallback = Aegis shield (rather than a "?"), so an unrecognised
|
||||
// registry entry still looks intentional.
|
||||
return logoSvg("aegis", s);
|
||||
}
|
||||
function testnetTag() { return `<span class="ttag">TEST</span>`; }
|
||||
|
||||
// Selected wallet convenience.
|
||||
const sel = () => state && state.selected;
|
||||
const chain = () => sel()?.chain || "";
|
||||
const decimals = () => sel()?.meta?.decimals || 8;
|
||||
const ticker = () => sel()?.meta?.ticker || "";
|
||||
|
||||
// Numbers past ~9e15 lose precision as JS `Number`, and Sia amounts live at
|
||||
// 10^24-scale routinely. Use BigInt for anything that arrives as a string.
|
||||
function fmtBig(units, dec) {
|
||||
const d = dec != null ? dec : decimals();
|
||||
if (typeof units === "string" && /^-?\d+$/.test(units)) {
|
||||
const neg = units.startsWith("-");
|
||||
const raw = neg ? units.slice(1) : units;
|
||||
const bi = BigInt(raw || "0");
|
||||
const base = 10n ** BigInt(d);
|
||||
const whole = (bi / base).toString();
|
||||
let frac = (bi % base).toString().padStart(d, "0").replace(/0+$/, "");
|
||||
// Show 8-digit precision at most for very small units; keep 2 dp minimum.
|
||||
const cap = Math.min(d, 8);
|
||||
if (frac.length > cap) frac = frac.slice(0, cap);
|
||||
if (!frac) frac = "";
|
||||
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
|
||||
}
|
||||
const s = (Number(units || 0) / Math.pow(10, d)).toFixed(d);
|
||||
return s.replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1");
|
||||
}
|
||||
function fmtSmall(units) {
|
||||
if (typeof units === "string" && /^-?\d+$/.test(units)) return units.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return Number(units || 0).toLocaleString("en-US");
|
||||
}
|
||||
function smallUnitLabel() {
|
||||
const c = chain();
|
||||
if (c === "bch" || c === "dgb" || c === "btc") return "sat";
|
||||
if (c === "trx") return "sun";
|
||||
if (c === "sc") return "H";
|
||||
if (c === "eth") return "wei";
|
||||
if (c === "sol") return "lamports";
|
||||
return "u";
|
||||
}
|
||||
// Some chains (SOL) suffix explorer URLs to tell devnet from mainnet.
|
||||
function explorerHref(base, id) {
|
||||
const s = sel();
|
||||
return base + id + (s?.explorerSuffix || "");
|
||||
}
|
||||
function bigUnitLabel() { return ticker(); }
|
||||
|
||||
// ---- tabs ------------------------------------------------------------------
|
||||
|
||||
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => showTab(b.dataset.tab)));
|
||||
function showTab(name) {
|
||||
tab = name;
|
||||
document.querySelectorAll("nav button").forEach((b) => b.classList.toggle("on", b.dataset.tab === name));
|
||||
document.querySelectorAll("main section").forEach((s) => { s.hidden = s.id !== "tab-" + name; });
|
||||
if (name === "settings") { settingsFilled = false; fillSettings(); }
|
||||
if (name === "send") applyUnitPicker();
|
||||
}
|
||||
|
||||
// ---- wallet picker (two-step add) ------------------------------------------
|
||||
|
||||
$("pickerBtn").addEventListener("click", (e) => {
|
||||
// The "+" chip inside the picker header opens the dropdown with the
|
||||
// Add-wallet section pre-expanded — same UX as the empty-state gate
|
||||
// button but always available.
|
||||
const isAddChip = e.target && (e.target.id === "hAddWallet" || e.target.closest("#hAddWallet"));
|
||||
const d = $("drop");
|
||||
if (isAddChip) {
|
||||
d.hidden = false;
|
||||
fillPicker();
|
||||
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
d.hidden = !d.hidden;
|
||||
if (!d.hidden) fillPicker();
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
const d = $("drop");
|
||||
if (d.hidden) return;
|
||||
if (e.target.closest("#drop") || e.target.closest("#pickerBtn")) return;
|
||||
d.hidden = true;
|
||||
});
|
||||
|
||||
function fillPicker() {
|
||||
const d = $("drop");
|
||||
const wallets = state?.wallets || [];
|
||||
const coins = state?.coins || [];
|
||||
const rowsHtml = wallets.map((w) => {
|
||||
const on = w.id === state.selectedWalletId ? "on" : "";
|
||||
const bal = w.balance ? fmtBig(w.balance.confirmed || 0, w.decimals) + " " + w.ticker : "—";
|
||||
const sub = `${esc(w.coinLabel)} · ${esc(w.networkLabel)}${w.testnet ? " " + testnetTag() : ""}`;
|
||||
return `<div class="row ${on}" data-select="${esc(w.id)}">
|
||||
${logoSvg(w.logo, 22)}
|
||||
<div class="m"><div class="l">${esc(w.label)}</div><div class="s">${sub}</div></div>
|
||||
<div class="v">${esc(bal)}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
// "Add wallet" is a two-step flyout: first show coins, then that coin's
|
||||
// networks. Nothing is created until the user clicks a specific network.
|
||||
const coinRows = coins.map((c) => {
|
||||
const testCount = c.networks.filter((n) => n.testnet).length;
|
||||
const sub = c.networks.length > 1
|
||||
? c.networks.map((n) => n.label).join(" · ")
|
||||
: c.networks[0].label;
|
||||
return `<div class="coinrow" data-coin="${esc(c.chain)}">
|
||||
${logoSvg(c.logo, 22)}
|
||||
<div class="m"><div class="l">${esc(c.label)}</div><div class="s">${esc(sub)}</div></div>
|
||||
<div class="caret">▸</div>
|
||||
</div>
|
||||
<div class="netgroup" id="netgroup-${esc(c.chain)}" hidden>
|
||||
${c.networks.map((n) => `<div class="netchoice" data-add="${esc(c.chain + ":" + n.id)}">
|
||||
${esc(n.label)}${n.testnet ? " " + testnetTag() : ""}
|
||||
</div>`).join("")}
|
||||
</div>`;
|
||||
}).join("");
|
||||
d.innerHTML =
|
||||
rowsHtml +
|
||||
`<hr><div class="addhdr">+ Add wallet</div>${coinRows}`;
|
||||
|
||||
d.querySelectorAll("[data-select]").forEach((r) => r.addEventListener("click", async () => {
|
||||
d.hidden = true;
|
||||
try { state = await S.invoke("selectWallet", { id: r.dataset.select }); settingsFilled = false; render(); }
|
||||
catch (e) { showErr(cleanErr(e)); }
|
||||
}));
|
||||
d.querySelectorAll(".coinrow").forEach((r) => r.addEventListener("click", () => {
|
||||
// Collapse other coins' network groups; toggle this one.
|
||||
d.querySelectorAll(".netgroup").forEach((g) => { if (g.id !== "netgroup-" + r.dataset.coin) g.hidden = true; });
|
||||
d.querySelectorAll(".coinrow .caret").forEach((c) => { c.textContent = "▸"; });
|
||||
const group = d.querySelector("#netgroup-" + r.dataset.coin);
|
||||
group.hidden = !group.hidden;
|
||||
r.querySelector(".caret").textContent = group.hidden ? "▸" : "▾";
|
||||
}));
|
||||
d.querySelectorAll("[data-add]").forEach((r) => r.addEventListener("click", async () => {
|
||||
const [c, n] = r.dataset.add.split(":");
|
||||
d.hidden = true;
|
||||
try { state = await S.invoke("addWallet", { chain: c, network: n }); settingsFilled = false; render(); }
|
||||
catch (e) { showErr(cleanErr(e)); }
|
||||
}));
|
||||
}
|
||||
function showErr(text) {
|
||||
const box = $("gate");
|
||||
box.hidden = false; box.innerHTML = `<div class="big">⚠</div><div>${esc(text)}</div>`;
|
||||
setTimeout(() => { if (state?.selected?.phase === "ready") { box.hidden = true; } }, 3500);
|
||||
}
|
||||
|
||||
// ---- render ----------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (!state) return;
|
||||
const s = sel();
|
||||
const ready = s && s.phase === "ready";
|
||||
$("tabs").hidden = !ready;
|
||||
const gate = $("gate");
|
||||
gate.hidden = ready;
|
||||
// Header: replace the badge slot with the coin's SVG and show
|
||||
// <wallet label> <coin · network + optional TEST tag>
|
||||
$("hBadge").innerHTML = s?.meta?.logo ? logoSvg(s.meta.logo, 22) : logoSvg(null, 22);
|
||||
$("hLabel").textContent = s?.label || "Aegis Wallet";
|
||||
$("hNet").innerHTML = s?.meta
|
||||
? `${esc(s.meta.coinLabel)} · ${esc(s.meta.networkLabel)}${s.meta.testnet ? " " + testnetTag() : ""}`
|
||||
: "";
|
||||
if (!ready) {
|
||||
const copy = {
|
||||
locked: ["🔒", "Unlock your password vault to open the wallet.", "Settings › Passwords. Aegis derives its keys from the vault seed, so there is nothing separate to unlock."],
|
||||
nosetup: ["🗝", "Set up a password vault to create your wallet.", "Settings › Passwords › Set up. Use a recovery phrase there and every wallet in Aegis can be recreated from it on any machine."],
|
||||
error: ["⚠", "This wallet could not start.", s?.error || ""],
|
||||
empty: ["🛡", "No wallets yet.", "Aegis derives every wallet from your Theseus password vault — there's no separate seed to import. Pick a coin below to create your first one."],
|
||||
}[s?.phase || "locked"] || ["…", "Starting…", ""];
|
||||
const showAddButton = s?.phase === "empty";
|
||||
gate.innerHTML = `<div class="big">${copy[0]}</div><div><b>${esc(copy[1])}</b></div><div class="hint" style="margin-top:8px">${esc(copy[2])}</div>`
|
||||
+ (showAddButton ? `<div class="actions" style="justify-content:center;margin-top:16px"><button class="btn primary" id="gateAddWallet">+ Add your first wallet</button></div>` : "");
|
||||
// Wire the empty-state button — opens the header picker with the Add
|
||||
// section pre-expanded so the user can pick a coin in one click.
|
||||
if (showAddButton) {
|
||||
const btn = $("gateAddWallet");
|
||||
if (btn) btn.addEventListener("click", () => {
|
||||
const d = $("drop");
|
||||
d.hidden = false;
|
||||
fillPicker();
|
||||
setTimeout(() => { const first = d.querySelector(".coinrow"); if (first) first.click(); }, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
const dot = $("dot");
|
||||
dot.className = "dot " + (s?.server ? (s?.scanning ? "busy" : "on") : "");
|
||||
$("netlbl").textContent = s?.server ? hostOf(s.server) + (s?.scanning ? " · syncing" : "") : (ready ? "connecting…" : (s?.network || ""));
|
||||
if (ready) {
|
||||
// Sia-specific gate: adapter is up, keys are derived, but no walletd URL
|
||||
// means no balance / history until the user configures one in Settings.
|
||||
if (chain() === "sc" && s.needsWalletdUrl) {
|
||||
$("balMain").textContent = "—"; $("balTicker").textContent = s.meta.ticker;
|
||||
$("netlbl").textContent = "point Aegis at a walletd node in Settings";
|
||||
$("tabs").hidden = true;
|
||||
gate.hidden = false;
|
||||
gate.innerHTML = `<div class="big">🗝</div><div><b>Point Aegis at a walletd node</b></div><div class="hint" style="margin-top:8px">Settings › Sia › walletd URL. Any public or self-hosted <span class="mono">go.sia.tech/walletd</span> in "full" index mode works.</div>`;
|
||||
return;
|
||||
}
|
||||
const total = balanceSum(s.balance);
|
||||
$("balMain").textContent = fmtBig(total);
|
||||
$("balTicker").textContent = s.meta.ticker;
|
||||
const uc = s.balance?.unconfirmed;
|
||||
if (uc && uc !== "0" && uc !== 0) $("netlbl").textContent += ` · ${fmtBig(uc)} unconfirmed`;
|
||||
} else {
|
||||
$("balMain").textContent = "—"; $("balTicker").textContent = "";
|
||||
}
|
||||
if (!ready) return;
|
||||
const addr = s.address || "";
|
||||
if ($("addr").textContent !== addr) {
|
||||
$("addr").textContent = addr;
|
||||
drawQr(qrPayload(chain(), addr, sel()?.network));
|
||||
}
|
||||
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
|
||||
$("nextAddr").hidden = chain() !== "bch";
|
||||
$("openFaucet").hidden = !s.faucet;
|
||||
// Render SPL tokens list (SOL wallets only). Sending a token clicks
|
||||
// through to the Send tab with that asset pre-picked.
|
||||
renderTokens();
|
||||
applyUnitPicker();
|
||||
$("feeField").hidden = chain() !== "bch";
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderTokens() {
|
||||
const s = sel();
|
||||
const tokens = (chain() === "sol" && s?.tokens) || [];
|
||||
const card = $("tokensCard");
|
||||
card.hidden = tokens.length === 0;
|
||||
if (!tokens.length) return;
|
||||
const el = $("tokensList");
|
||||
el.innerHTML = tokens.map((t) => {
|
||||
const dec = Number(t.decimals) || 0;
|
||||
const bal = fmtTokenAmount(t.balance, dec);
|
||||
return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default">
|
||||
<div><div>${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + '</span>' : ""}</div><div class="hint mono">${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}</div></div>
|
||||
<div class="amt2 in" style="align-self:center">${esc(bal)}</div>
|
||||
<button class="btn sm" data-mint="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${dec}" style="align-self:center">Send</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => {
|
||||
sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) };
|
||||
showTab("send");
|
||||
}));
|
||||
}
|
||||
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.
|
||||
function fmtTokenAmount(rawStr, decimals) {
|
||||
const s = String(rawStr || "0");
|
||||
const neg = s.startsWith("-");
|
||||
const abs = neg ? s.slice(1) : s;
|
||||
const d = Number(decimals) || 0;
|
||||
if (d === 0) return (neg ? "-" : "") + abs;
|
||||
const pad = abs.padStart(d + 1, "0");
|
||||
const whole = pad.slice(0, pad.length - d);
|
||||
const frac = pad.slice(pad.length - d).replace(/0+$/, "");
|
||||
return (neg ? "-" : "") + whole + (frac ? "." + frac : "");
|
||||
}
|
||||
|
||||
function applyUnitPicker() {
|
||||
const s = sel(); if (!s) return;
|
||||
if (!unit) unit = "big";
|
||||
// ---- SPL asset picker (SOL wallets with tokens) ---------------------
|
||||
const tokens = (chain() === "sol" && s.tokens) || [];
|
||||
const assetField = $("sendAssetField");
|
||||
if (tokens.length) {
|
||||
assetField.hidden = false;
|
||||
const sel_ = $("sendAsset");
|
||||
// Rebuild whenever the asset set changes so a new token appears.
|
||||
const key = tokens.map((t) => t.mint).join("|");
|
||||
if (sel_.dataset.key !== key) {
|
||||
sel_.dataset.key = key;
|
||||
sel_.innerHTML = `<option value="">SOL — native</option>` + tokens.map((t) =>
|
||||
`<option value="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${Number(t.decimals) || 0}">${esc(t.symbol)}${t.name ? " · " + esc(t.name) : ""}</option>`
|
||||
).join("");
|
||||
sel_.onchange = () => {
|
||||
const opt = sel_.options[sel_.selectedIndex];
|
||||
sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null;
|
||||
applyUnitPicker(); schedulePlan();
|
||||
};
|
||||
}
|
||||
// Reflect the current sendAsset back into the select.
|
||||
sel_.value = sendAsset ? sendAsset.mint : "";
|
||||
} else {
|
||||
assetField.hidden = true;
|
||||
sendAsset = null;
|
||||
}
|
||||
const isToken = sendAsset != null;
|
||||
const big = isToken ? sendAsset.symbol : bigUnitLabel();
|
||||
const small = isToken ? "raw" : smallUnitLabel();
|
||||
$("unitPicker").innerHTML =
|
||||
`<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` +
|
||||
`<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`;
|
||||
$("unitPicker").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => setUnit(b.dataset.u)));
|
||||
$("sendTo").placeholder = ({
|
||||
bch: s.network === "chipnet" ? "bchtest:q… or legacy m…" : "bitcoincash:q… or legacy 1…",
|
||||
btc: s.network === "testnet" ? "tb1q… (or 2… / m…, n…)" : "bc1q… (or bc1p…, 3…, 1…)",
|
||||
trx: "T… (base58check, 34 chars)",
|
||||
sc: "addr1… (76-hex + checksum)",
|
||||
dgb: "dgb1q… (or D… / S… depending on family)",
|
||||
eth: "0x… (40 hex chars, EIP-55)",
|
||||
sol: "base58 public key (32 bytes)",
|
||||
})[chain()] || "recipient address";
|
||||
$("sendAmt").placeholder = unit === "big" ? "0.00" : "0";
|
||||
}
|
||||
function setUnit(u) {
|
||||
if (u === unit) return;
|
||||
const s = amountUnits();
|
||||
unit = u;
|
||||
applyUnitPicker();
|
||||
if (s) $("sendAmt").value = unit === "big" ? fmtBig(s) : String(s);
|
||||
}
|
||||
function amountUnits() {
|
||||
const raw = $("sendAmt").value.trim().replace(/,/g, "");
|
||||
if (!raw) return 0;
|
||||
// For SPL tokens the amount is a raw u64 string in the token's own
|
||||
// smallest unit — same BigInt-safe path SC uses.
|
||||
const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals();
|
||||
const bigDecimals = sendAsset != null || d > 15;
|
||||
if (unit === "small") {
|
||||
if (bigDecimals) return raw.replace(/\D+/g, "") || "0";
|
||||
return Math.round(Number(raw));
|
||||
}
|
||||
const [w, f = ""] = raw.split(".");
|
||||
const frac = (f + "0".repeat(d)).slice(0, d);
|
||||
if (bigDecimals) {
|
||||
const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
|
||||
return total.toString();
|
||||
}
|
||||
return Number(w || 0) * Math.pow(10, d) + Number(frac || 0);
|
||||
}
|
||||
// Sum "confirmed + unconfirmed" BigInt-safely (strings for SC, numbers elsewhere).
|
||||
function balanceSum(b) {
|
||||
if (!b) return 0;
|
||||
if (typeof b.confirmed === "string" || typeof b.unconfirmed === "string") {
|
||||
return (BigInt(b.confirmed || "0") + BigInt(b.unconfirmed || "0")).toString();
|
||||
}
|
||||
return (b.confirmed || 0) + (b.unconfirmed || 0);
|
||||
}
|
||||
|
||||
// ---- history ---------------------------------------------------------------
|
||||
|
||||
function renderHistory() {
|
||||
const s = sel();
|
||||
const list = s?.history || [];
|
||||
const el = $("txlist");
|
||||
if (!list.length) { el.innerHTML = `<div class="empty">${s?.scanning ? "Syncing…" : "No transactions yet."}</div>`; return; }
|
||||
el.innerHTML = list.map((t) => {
|
||||
const inc = t.delta >= 0;
|
||||
const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending";
|
||||
const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : "");
|
||||
const what = (inc ? "Received" : "Sent") + (who ? " " + who : "");
|
||||
const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed");
|
||||
const delta = Math.abs(t.delta || 0);
|
||||
return `<div class="tx" data-txid="${esc(t.txid)}" title="${esc(t.txid)}">
|
||||
<div class="ic ${inc ? "in" : "out"}">${inc ? "↓" : "↑"}</div>
|
||||
<div class="what">${esc(what)}</div>
|
||||
<div class="amt2 ${inc ? "in" : ""}">${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}</div>
|
||||
<div class="when">${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}</div>
|
||||
<div class="conf ${t.confirmations > 0 ? (t.status === "failed" ? "pending" : "") : "pending"}">${esc(conf)}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
el.querySelectorAll(".tx").forEach((row) => row.addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, row.dataset.txid))));
|
||||
}
|
||||
function shortAddr(a) {
|
||||
if (!a) return "";
|
||||
const s = String(a).replace(/^bitcoincash:|^bchtest:/, "");
|
||||
return esc(s.slice(0, 10)) + "…" + esc(s.slice(-4));
|
||||
}
|
||||
|
||||
// ---- QR --------------------------------------------------------------------
|
||||
|
||||
// Coin-scheme URI so wallet apps that scan know which chain the payment is
|
||||
// for. Follows each chain's own convention (BIP21 for BTC-family, EIP-681
|
||||
// for ETH, Solana Pay for SOL, bare address for SC where no widely-agreed
|
||||
// URI scheme exists).
|
||||
function qrPayload(chain, address, network) {
|
||||
if (chain === "bch") return (network === "chipnet" ? "bchtest:" : "bitcoincash:") + String(address).replace(/^bitcoincash:|^bchtest:/, "");
|
||||
if (chain === "btc") return "bitcoin:" + address; // BIP21
|
||||
if (chain === "dgb") return "digibyte:" + address;
|
||||
if (chain === "eth") return "ethereum:" + address;
|
||||
if (chain === "sol") return "solana:" + address;
|
||||
if (chain === "trx") return "tron:" + address;
|
||||
return String(address);
|
||||
}
|
||||
function drawQr(text) {
|
||||
const cv = $("qr");
|
||||
const g = cv.getContext("2d");
|
||||
let q;
|
||||
try { q = window.QR.build(text); } catch { g.clearRect(0, 0, cv.width, cv.height); return; }
|
||||
const scale = Math.max(2, Math.floor(200 / (q.size + 2)));
|
||||
const px = (q.size + 2) * scale;
|
||||
cv.width = cv.height = px;
|
||||
cv.style.width = cv.style.height = px + "px";
|
||||
g.fillStyle = "#fff"; g.fillRect(0, 0, px, px);
|
||||
g.fillStyle = "#000";
|
||||
for (let r = 0; r < q.size; r++) for (let c = 0; c < q.size; c++) if (q.modules[r][c]) g.fillRect((c + 1) * scale, (r + 1) * scale, scale, scale);
|
||||
}
|
||||
|
||||
// ---- receive actions -------------------------------------------------------
|
||||
|
||||
$("copyAddr").addEventListener("click", async () => {
|
||||
try { await navigator.clipboard.writeText(sel().address); flash($("copyAddr"), "Copied"); } catch {}
|
||||
});
|
||||
$("nextAddr").addEventListener("click", async () => {
|
||||
try { const s = await S.invoke("nextAddress"); state.selected = { ...state.selected, ...s }; render(); }
|
||||
catch (e) { flash($("nextAddr"), "Failed"); }
|
||||
});
|
||||
$("viewAddr").addEventListener("click", () => openUrl(explorerHref(sel().explorerAddr, sel().address)));
|
||||
$("openFaucet").addEventListener("click", () => sel().faucet && openUrl(sel().faucet));
|
||||
function flash(btn, text) {
|
||||
const old = btn.textContent; btn.textContent = text;
|
||||
setTimeout(() => { btn.textContent = old; }, 1200);
|
||||
}
|
||||
|
||||
// ---- send ------------------------------------------------------------------
|
||||
|
||||
$("sendMax").addEventListener("click", () => {
|
||||
sendMax = !sendMax;
|
||||
$("sendMax").classList.toggle("primary", sendMax);
|
||||
$("sendAmt").disabled = sendMax;
|
||||
if (!sendMax) $("sendAmt").value = "";
|
||||
schedulePlan();
|
||||
});
|
||||
$("feeRate").addEventListener("input", () => { $("feeLbl").textContent = $("feeRate").value + " sat/B"; schedulePlan(); });
|
||||
["sendTo", "sendAmt"].forEach((id) => $(id).addEventListener("input", () => { if (id === "sendAmt" && sendMax) return; schedulePlan(); }));
|
||||
function schedulePlan() { clearTimeout(planTimer); planTimer = setTimeout(updatePlan, 250); }
|
||||
async function updatePlan() {
|
||||
const to = $("sendTo").value.trim();
|
||||
const msg = $("sendMsg"); msg.hidden = true;
|
||||
lastPlan = null; $("sendBtn").disabled = true;
|
||||
$("sumAmt").textContent = $("sumFee").textContent = $("sumTotal").textContent = "—";
|
||||
$("sendToHint").textContent = "";
|
||||
if (!to || (!sendMax && !amountUnits())) return;
|
||||
try {
|
||||
if (sendAsset) {
|
||||
// SPL token flow — amount is raw units of the token's decimals.
|
||||
const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() });
|
||||
lastPlan = { _token: true, ...p };
|
||||
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
|
||||
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
|
||||
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
|
||||
$("sendBtn").disabled = false;
|
||||
return;
|
||||
}
|
||||
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
|
||||
const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax });
|
||||
lastPlan = p;
|
||||
$("sendToHint").textContent = p.recipients[0].to !== to ? "→ " + p.recipients[0].to : "";
|
||||
$("sumAmt").textContent = fmtBig(p.recipients[0].value) + " " + ticker();
|
||||
$("sumFee").textContent = chain() === "bch"
|
||||
? fmtSmall(p.fee) + " " + smallUnitLabel()
|
||||
: fmtBig(p.fee) + " " + ticker();
|
||||
$("sumTotal").textContent = fmtBig(p.total) + " " + ticker();
|
||||
if (sendMax) $("sendAmt").value = unit === "big" ? fmtBig(p.recipients[0].value) : String(p.recipients[0].value);
|
||||
$("sendBtn").disabled = false;
|
||||
} catch (e) {
|
||||
msg.className = "msg err"; msg.textContent = cleanErr(e); msg.hidden = false;
|
||||
}
|
||||
}
|
||||
$("sendBtn").addEventListener("click", async () => {
|
||||
if (!lastPlan) return;
|
||||
const msg = $("sendMsg"); msg.hidden = true;
|
||||
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
|
||||
try {
|
||||
const isToken = sendAsset && lastPlan._token;
|
||||
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
|
||||
const r = isToken
|
||||
? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() })
|
||||
: await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax });
|
||||
msg.className = "msg ok";
|
||||
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
|
||||
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));
|
||||
msg.hidden = false;
|
||||
$("sendTo").value = ""; $("sendAmt").value = ""; sendMax = false;
|
||||
$("sendMax").classList.remove("primary"); $("sendAmt").disabled = false;
|
||||
lastPlan = null;
|
||||
} catch (e) {
|
||||
const t = cleanErr(e);
|
||||
if (t !== "cancelled") { msg.className = "msg err"; msg.textContent = t; msg.hidden = false; }
|
||||
$("sendBtn").disabled = !lastPlan;
|
||||
} finally { $("sendBtn").textContent = "Send"; }
|
||||
});
|
||||
|
||||
// ---- settings --------------------------------------------------------------
|
||||
|
||||
function fillSettings() {
|
||||
const s = sel(); if (!s) return;
|
||||
$("bchSettings").hidden = chain() !== "bch";
|
||||
$("trxSettings").hidden = chain() !== "trx";
|
||||
$("scSettings").hidden = chain() !== "sc";
|
||||
$("dgbSettings").hidden = chain() !== "dgb";
|
||||
$("btcSettings").hidden = chain() !== "btc";
|
||||
$("ethSettings").hidden = chain() !== "eth";
|
||||
$("solSettings").hidden = chain() !== "sol";
|
||||
$("removeBtn").disabled = !!s.isLegacy && s.chain === "bch";
|
||||
$("removeHint").textContent = (s.isLegacy && s.chain === "bch")
|
||||
? "The default BCH wallet cannot be removed (it protects legacy funds)."
|
||||
: (s.isLegacy && s.chain === "sc" ? "Removing this wallet unlinks it from Aegis. Funds stay on-chain and reappear if you add a Siacoin wallet again with the legacy seed slot." : "");
|
||||
$("renameLabel").value = s.label || "";
|
||||
if (chain() === "bch") {
|
||||
if (!settingsFilled) {
|
||||
$("setPath").value = s.accountPath || "";
|
||||
$("setServers").value = (state.bchServers?.list || []).join("\n");
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("bchServersRow").hidden = s.network !== "mainnet";
|
||||
$("serverHint").textContent = s.network !== "mainnet"
|
||||
? "Chipnet uses bundled defaults in this build."
|
||||
: (state.bchServers?.custom ? "Custom list." : "Bundled defaults.") + (s.server ? " Connected to " + hostOf(s.server) + "." : " Not connected.");
|
||||
$("purpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
} else if (chain() === "trx") {
|
||||
$("trxPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
} else if (chain() === "sc") {
|
||||
if (!settingsFilled) {
|
||||
$("setWalletdUrl").value = s.walletdUrl || "";
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("scPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
$("scRecovery").innerHTML = "";
|
||||
} else if (chain() === "dgb") {
|
||||
if (!settingsFilled) {
|
||||
fillFamilyPicker("Dgb", s);
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("dgbPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
$("dgbRecovery").innerHTML = "";
|
||||
} else if (chain() === "btc") {
|
||||
if (!settingsFilled) {
|
||||
fillFamilyPicker("Btc", s);
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("btcPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
$("btcRecovery").innerHTML = "";
|
||||
} else if (chain() === "eth") {
|
||||
if (!settingsFilled) {
|
||||
$("setEthRpcUrl").value = s.rpcUrl || "";
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("ethPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
$("ethRecovery").innerHTML = "";
|
||||
} else if (chain() === "sol") {
|
||||
if (!settingsFilled) {
|
||||
$("setSolRpcUrl").value = s.rpcUrl || "";
|
||||
settingsFilled = true;
|
||||
}
|
||||
$("solPurpose").textContent = "silentmode/addons/" + (s.purpose || "");
|
||||
$("solRecovery").innerHTML = "";
|
||||
}
|
||||
renderSites();
|
||||
}
|
||||
async function renderSites() {
|
||||
let perms = {};
|
||||
try { perms = await S.invoke("permissions"); } catch {}
|
||||
const origins = Object.keys(perms).filter((o) => {
|
||||
const p = perms[o];
|
||||
return p && (p.readAddress || p.sendTx || (p.trx && p.trx.readAddress));
|
||||
});
|
||||
const el = $("sites");
|
||||
if (!origins.length) { el.innerHTML = `<div class="hint">None yet.</div>`; return; }
|
||||
el.innerHTML = origins.map((o) => {
|
||||
const p = perms[o]; const what = [];
|
||||
if (p.readAddress) what.push("BCH address");
|
||||
if (p.sendTx) what.push(`BCH payments: ${fmtBig(Math.max(0, p.sendTx.capSats - (p.sendTx.usedSats || 0)), 8)} of ${fmtBig(p.sendTx.capSats, 8)} BCH left`);
|
||||
if (p.trx && p.trx.readAddress) what.push("Tron " + (p.trx.network === "nile" ? "Nile testnet" : "mainnet") + " address");
|
||||
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default"><div><div class="mono">${esc(o)}</div><div class="hint">${esc(what.join(" · "))}</div></div><button class="btn sm" data-origin="${esc(o)}">Revoke</button></div>`;
|
||||
}).join("");
|
||||
el.querySelectorAll("button[data-origin]").forEach((b) => b.addEventListener("click", async () => {
|
||||
try { await S.invoke("revoke", { origin: b.dataset.origin }); renderSites(); } catch {}
|
||||
}));
|
||||
}
|
||||
|
||||
$("applySettings").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
const path = $("setPath").value.trim();
|
||||
const servers = $("setServers").value.split(/\n+/).map((s) => s.trim()).filter(Boolean);
|
||||
if (path && path !== (sel().accountPath || "")) {
|
||||
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: path });
|
||||
}
|
||||
if (sel().network === "mainnet") {
|
||||
const currentJoined = (state.bchServers?.list || []).join();
|
||||
if (state.bchServers?.custom || servers.join() !== currentJoined) {
|
||||
state = await S.invoke("setBchServers", { servers });
|
||||
}
|
||||
}
|
||||
settingsFilled = false; fillSettings(); render();
|
||||
flash($("applySettings"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("resetServers").addEventListener("click", async () => {
|
||||
try { state = await S.invoke("setBchServers", { servers: [] }); settingsFilled = false; fillSettings(); render(); }
|
||||
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
|
||||
});
|
||||
$("renameBtn").addEventListener("click", async () => {
|
||||
const label = $("renameLabel").value.trim();
|
||||
if (!label) return;
|
||||
try { state = await S.invoke("renameWallet", { id: state.selectedWalletId, label }); render(); flash($("renameBtn"), "Renamed"); }
|
||||
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
|
||||
});
|
||||
$("removeBtn").addEventListener("click", async () => {
|
||||
const s = sel(); if (!s || s.isLegacy) return;
|
||||
if (!confirm(`Remove the wallet "${s.label}"?\n\nThe on-chain address stays; the wallet is unlinked from Aegis. You can add it back later by creating a new wallet on the same coin + network.`)) return;
|
||||
try { state = await S.invoke("removeWallet", { id: state.selectedWalletId }); settingsFilled = false; render(); }
|
||||
catch (e) { $("settingsMsg").textContent = cleanErr(e); $("settingsMsg").hidden = false; }
|
||||
});
|
||||
$("showXpub").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("recovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("recovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showXprv").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("recovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("recovery").textContent = cleanErr(e); }
|
||||
});
|
||||
function recoveryHtml(r) {
|
||||
let h = `<div class="lbl">Account path</div><div class="mono">${esc(r.accountPath)}</div><div class="lbl">Account xpub</div><div class="mono">${esc(r.xpub)}</div>`;
|
||||
if (r.xprv) h += `<div class="lbl">Account private key (xprv)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>`;
|
||||
return h;
|
||||
}
|
||||
document.querySelectorAll("nav button").forEach((b) => b.addEventListener("click", () => {
|
||||
if (b.dataset.tab !== "settings") {
|
||||
$("recovery").innerHTML = "";
|
||||
$("scRecovery").innerHTML = "";
|
||||
$("dgbRecovery").innerHTML = "";
|
||||
$("btcRecovery").innerHTML = "";
|
||||
$("ethRecovery").innerHTML = "";
|
||||
$("solRecovery").innerHTML = "";
|
||||
}
|
||||
}));
|
||||
|
||||
// Sia-specific settings.
|
||||
$("applyWalletdUrl").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
state = await S.invoke("setWalletdUrl", { id: state.selectedWalletId, walletdUrl: $("setWalletdUrl").value.trim() });
|
||||
settingsFilled = false; fillSettings(); render(); flash($("applyWalletdUrl"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("showScSeed").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
|
||||
$("scRecovery").innerHTML =
|
||||
`<div class="lbl">First address (index 0)</div><div class="mono">${esc(r.xpub || "")}</div>` +
|
||||
(r.xprv ? `<div class="lbl">Wallet seed (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
|
||||
} catch (e) { $("scRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
|
||||
// Family-picker helper used by both DGB and BTC. Prefix is "Dgb" or "Btc":
|
||||
// the DOM IDs are #set<Prefix>Family + #set<Prefix>Path.
|
||||
function fillFamilyPicker(prefix, s) {
|
||||
const families = s.meta?.addressFamilies || [];
|
||||
const current = String(s.accountPath || "");
|
||||
let currentId = families.find((f) => f.defaultAccountPath === current)?.id;
|
||||
if (!currentId) {
|
||||
const m = /^m\/(\d+)'/.exec(current);
|
||||
const purpose = m ? Number(m[1]) : null;
|
||||
currentId = families.find((f) => f.purpose === purpose)?.id || families[0]?.id;
|
||||
}
|
||||
$(`set${prefix}Path`).value = current || families[0]?.defaultAccountPath || "";
|
||||
$(`set${prefix}Family`).innerHTML = families.map((f) =>
|
||||
`<option value="${esc(f.id)}" data-path="${esc(f.defaultAccountPath)}" ${f.id === currentId ? "selected" : ""}>${esc(f.label)}</option>`
|
||||
).join("");
|
||||
}
|
||||
// Any family select → auto-fill the sibling path input.
|
||||
document.addEventListener("change", (e) => {
|
||||
const t = e.target;
|
||||
if (!t) return;
|
||||
const m = /^set(Dgb|Btc)Family$/.exec(t.id || "");
|
||||
if (!m) return;
|
||||
const opt = t.options[t.selectedIndex];
|
||||
if (opt && opt.dataset.path) $(`set${m[1]}Path`).value = opt.dataset.path;
|
||||
});
|
||||
$("applyDgbPath").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setDgbPath").value.trim() });
|
||||
settingsFilled = false; fillSettings(); render(); flash($("applyDgbPath"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("applyBtcPath").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
state = await S.invoke("setAccountPath", { id: state.selectedWalletId, accountPath: $("setBtcPath").value.trim() });
|
||||
settingsFilled = false; fillSettings(); render(); flash($("applyBtcPath"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("showBtcXpub").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("btcRecovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showBtcXprv").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("btcRecovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("btcRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
|
||||
// ETH / SOL: RPC URL.
|
||||
$("applyEthRpc").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setEthRpcUrl").value.trim() });
|
||||
settingsFilled = false; fillSettings(); render(); flash($("applyEthRpc"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("applySolRpc").addEventListener("click", async () => {
|
||||
const msg = $("settingsMsg"); msg.hidden = true;
|
||||
try {
|
||||
state = await S.invoke("setRpcUrl", { id: state.selectedWalletId, rpcUrl: $("setSolRpcUrl").value.trim() });
|
||||
settingsFilled = false; fillSettings(); render(); flash($("applySolRpc"), "Applied");
|
||||
} catch (e) { msg.textContent = cleanErr(e); msg.hidden = false; }
|
||||
});
|
||||
$("showEthKey").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
|
||||
$("ethRecovery").innerHTML =
|
||||
`<div class="lbl">Address</div><div class="mono">${esc(sel().address || "")}</div>` +
|
||||
`<div class="lbl">Public key (uncompressed hex)</div><div class="mono">${esc(r.xpub || "")}</div>` +
|
||||
(r.xprv ? `<div class="lbl">Private key (hex)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
|
||||
} catch (e) { $("ethRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showSolKey").addEventListener("click", async () => {
|
||||
try {
|
||||
const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true });
|
||||
$("solRecovery").innerHTML =
|
||||
`<div class="lbl">Address (public key, base58)</div><div class="mono">${esc(r.xpub || "")}</div>` +
|
||||
(r.xprv ? `<div class="lbl">Wallet seed (hex, 32 bytes)</div><div class="mono" style="color:var(--danger)">${esc(r.xprv)}</div>` : "");
|
||||
} catch (e) { $("solRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showDgbXpub").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
$("showDgbXprv").addEventListener("click", async () => {
|
||||
try { const r = await S.invoke("recovery", { id: state.selectedWalletId, reveal: true }); $("dgbRecovery").innerHTML = recoveryHtml(r); }
|
||||
catch (e) { $("dgbRecovery").textContent = cleanErr(e); }
|
||||
});
|
||||
|
||||
// ---- boot ------------------------------------------------------------------
|
||||
S.on("state", (s) => { state = s; render(); if (tab === "settings") fillSettings(); });
|
||||
(async () => {
|
||||
try { state = await S.invoke("state"); render(); }
|
||||
catch (e) { $("gate").hidden = false; $("gate").innerHTML = `<div class="big">⚠</div><div>${esc(cleanErr(e))}</div>`; }
|
||||
})();
|
||||
218
qr.js
Normal file
218
qr.js
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// Minimal QR encoder for the receive tab: byte mode, versions 1-10, error
|
||||
// correction M (falls back to L when M won't fit). Returns a boolean matrix.
|
||||
// Loaded as a plain script in panel.html and as a CommonJS module in tests.
|
||||
(function (root) {
|
||||
const EC_LEVELS = { L: 1, M: 0 };
|
||||
// Per version: [totalCodewords, {L:[ecPerBlock, [[blocks, dataCw], ...]], M:[...]}]
|
||||
const TABLE = {
|
||||
1: [26, { L: [7, [[1, 19]]], M: [10, [[1, 16]]] }],
|
||||
2: [44, { L: [10, [[1, 34]]], M: [16, [[1, 28]]] }],
|
||||
3: [70, { L: [15, [[1, 55]]], M: [26, [[1, 44]]] }],
|
||||
4: [100, { L: [20, [[1, 80]]], M: [18, [[2, 32]]] }],
|
||||
5: [134, { L: [26, [[1, 108]]], M: [24, [[2, 43]]] }],
|
||||
6: [172, { L: [18, [[2, 68]]], M: [16, [[4, 27]]] }],
|
||||
7: [196, { L: [20, [[2, 78]]], M: [18, [[4, 31]]] }],
|
||||
8: [242, { L: [24, [[2, 97]]], M: [22, [[2, 38], [2, 39]]] }],
|
||||
9: [292, { L: [30, [[2, 116]]], M: [22, [[3, 36], [2, 37]]] }],
|
||||
10: [346, { L: [18, [[2, 68], [2, 69]]], M: [26, [[4, 43], [1, 44]]] }],
|
||||
};
|
||||
const ALIGN = { 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34], 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50] };
|
||||
const REMAINDER = { 1: 0, 2: 7, 3: 7, 4: 7, 5: 7, 6: 7, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
|
||||
// GF(256) with the QR polynomial 0x11d.
|
||||
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
|
||||
(function () {
|
||||
let x = 1;
|
||||
for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
|
||||
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
||||
})();
|
||||
const gmul = (a, b) => (a && b) ? EXP[LOG[a] + LOG[b]] : 0;
|
||||
function generator(n) {
|
||||
let g = [1];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const next = new Array(g.length + 1).fill(0);
|
||||
for (let j = 0; j < g.length; j++) { next[j] ^= g[j]; next[j + 1] ^= gmul(g[j], EXP[i]); }
|
||||
g = next;
|
||||
}
|
||||
return g;
|
||||
}
|
||||
function rsEncode(data, nEc) {
|
||||
const g = generator(nEc);
|
||||
const out = new Uint8Array(data.length + nEc);
|
||||
out.set(data);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const c = out[i];
|
||||
if (c) for (let j = 1; j < g.length; j++) out[i + j] ^= gmul(g[j], c);
|
||||
}
|
||||
return out.slice(data.length);
|
||||
}
|
||||
|
||||
function dataCapacity(v, ec) { return TABLE[v][1][ec][1].reduce((a, [b, d]) => a + b * d, 0); }
|
||||
function pickVersion(len) {
|
||||
for (const ec of ["M", "L"]) {
|
||||
for (let v = 1; v <= 10; v++) {
|
||||
const bitsNeeded = 4 + (v <= 9 ? 8 : 16) + len * 8;
|
||||
if (bitsNeeded <= dataCapacity(v, ec) * 8) return { v, ec };
|
||||
}
|
||||
}
|
||||
throw new Error("qr: text too long");
|
||||
}
|
||||
|
||||
function encodeData(bytes, v, ec) {
|
||||
const cap = dataCapacity(v, ec);
|
||||
const bits = [];
|
||||
const push = (val, n) => { for (let i = n - 1; i >= 0; i--) bits.push((val >> i) & 1); };
|
||||
push(0b0100, 4);
|
||||
push(bytes.length, v <= 9 ? 8 : 16);
|
||||
for (const b of bytes) push(b, 8);
|
||||
for (let i = 0; i < 4 && bits.length < cap * 8; i++) bits.push(0);
|
||||
while (bits.length % 8) bits.push(0);
|
||||
const data = new Uint8Array(cap);
|
||||
for (let i = 0; i < bits.length / 8; i++) data[i] = parseInt(bits.slice(i * 8, i * 8 + 8).join(""), 2);
|
||||
for (let i = bits.length / 8, k = 0; i < cap; i++, k++) data[i] = k % 2 ? 0x11 : 0xec;
|
||||
return data;
|
||||
}
|
||||
|
||||
function interleave(data, v, ec) {
|
||||
const [nEc, groups] = TABLE[v][1][ec];
|
||||
const blocks = []; let o = 0;
|
||||
for (const [count, size] of groups) for (let i = 0; i < count; i++) { blocks.push(data.slice(o, o + size)); o += size; }
|
||||
const ecBlocks = blocks.map((b) => rsEncode(b, nEc));
|
||||
const out = [];
|
||||
const maxLen = Math.max(...blocks.map((b) => b.length));
|
||||
for (let i = 0; i < maxLen; i++) for (const b of blocks) if (i < b.length) out.push(b[i]);
|
||||
for (let i = 0; i < nEc; i++) for (const b of ecBlocks) out.push(b[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Remainder of value·x^degree modulo the generator poly (BCH error correction
|
||||
// for the format / version fields).
|
||||
function bch(value, poly, degree) {
|
||||
let v = value << degree;
|
||||
for (let i = 31 - Math.clz32(v); i >= degree; i--) if ((v >> i) & 1) v ^= poly << (i - degree);
|
||||
return v;
|
||||
}
|
||||
const formatBits = (ec, mask) => { const d = (EC_LEVELS[ec] << 3) | mask; return ((d << 10) | bch(d, 0x537, 10)) ^ 0x5412; };
|
||||
const versionBits = (v) => (v << 12) | bch(v, 0x1f25, 12);
|
||||
|
||||
function build(text) {
|
||||
const bytes = typeof text === "string" ? new TextEncoder().encode(text) : Uint8Array.from(text);
|
||||
const { v, ec } = pickVersion(bytes.length);
|
||||
const size = v * 4 + 17;
|
||||
const codewords = interleave(encodeData(bytes, v, ec), v, ec);
|
||||
const grid = Array.from({ length: size }, () => new Uint8Array(size)); // 1 dark, 0 light
|
||||
const fixed = Array.from({ length: size }, () => new Uint8Array(size)); // function pattern / reserved
|
||||
const set = (r, c, val) => { grid[r][c] = val ? 1 : 0; fixed[r][c] = 1; };
|
||||
const finder = (r0, c0) => {
|
||||
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
|
||||
const rr = r0 + r, cc = c0 + c;
|
||||
if (rr < 0 || cc < 0 || rr >= size || cc >= size) continue;
|
||||
const inner = r >= 0 && r <= 6 && c >= 0 && c <= 6;
|
||||
const dark = inner && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
||||
set(rr, cc, dark);
|
||||
}
|
||||
};
|
||||
finder(0, 0); finder(0, size - 7); finder(size - 7, 0);
|
||||
for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||
const al = ALIGN[v], last = al.length - 1;
|
||||
al.forEach((r, ri) => al.forEach((c, ci) => {
|
||||
// The three corners that would sit on a finder pattern are omitted.
|
||||
if ((ri === 0 && ci === 0) || (ri === 0 && ci === last) || (ri === last && ci === 0)) return;
|
||||
for (let dr = -2; dr <= 2; dr++) for (let dc = -2; dc <= 2; dc++) {
|
||||
set(r + dr, c + dc, Math.max(Math.abs(dr), Math.abs(dc)) !== 1);
|
||||
}
|
||||
}));
|
||||
set(size - 8, 8, 1); // dark module
|
||||
// Reserve format areas (filled per mask below) and version areas.
|
||||
for (let i = 0; i < 9; i++) { fixed[8][i] = 1; fixed[i][8] = 1; }
|
||||
for (let i = 0; i < 8; i++) { fixed[8][size - 1 - i] = 1; fixed[size - 1 - i][8] = 1; }
|
||||
if (v >= 7) {
|
||||
const vb = versionBits(v);
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const bit = (vb >> i) & 1;
|
||||
set(Math.floor(i / 3), size - 11 + (i % 3), bit);
|
||||
set(size - 11 + (i % 3), Math.floor(i / 3), bit);
|
||||
}
|
||||
}
|
||||
// Zigzag data placement.
|
||||
const bits = [];
|
||||
for (const cw of codewords) for (let i = 7; i >= 0; i--) bits.push((cw >> i) & 1);
|
||||
for (let i = 0; i < REMAINDER[v]; i++) bits.push(0);
|
||||
let bi = 0, up = true;
|
||||
for (let col = size - 1; col > 0; col -= 2) {
|
||||
if (col === 6) col--;
|
||||
for (let k = 0; k < size; k++) {
|
||||
const r = up ? size - 1 - k : k;
|
||||
for (const c of [col, col - 1]) {
|
||||
if (fixed[r][c]) continue;
|
||||
grid[r][c] = bi < bits.length ? bits[bi] : 0;
|
||||
bi++;
|
||||
}
|
||||
}
|
||||
up = !up;
|
||||
}
|
||||
// Try every mask, keep the lowest penalty.
|
||||
const MASKS = [
|
||||
(i, j) => (i + j) % 2 === 0, (i) => i % 2 === 0, (_i, j) => j % 3 === 0, (i, j) => (i + j) % 3 === 0,
|
||||
(i, j) => (Math.floor(i / 2) + Math.floor(j / 3)) % 2 === 0, (i, j) => ((i * j) % 2) + ((i * j) % 3) === 0,
|
||||
(i, j) => (((i * j) % 2) + ((i * j) % 3)) % 2 === 0, (i, j) => (((i + j) % 2) + ((i * j) % 3)) % 2 === 0,
|
||||
];
|
||||
let best = null;
|
||||
for (let m = 0; m < 8; m++) {
|
||||
const g = grid.map((row) => Uint8Array.from(row));
|
||||
for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) if (!fixed[r][c] && MASKS[m](r, c)) g[r][c] ^= 1;
|
||||
const fb = formatBits(ec, m);
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const bit = (fb >> i) & 1;
|
||||
// Copy 1: down column 8 (bits 0-7, skipping the timing row), then
|
||||
// left along row 8 (bits 8-14). Copy 2: right end of row 8, bottom
|
||||
// of column 8.
|
||||
if (i < 6) g[i][8] = bit; else if (i === 6) g[7][8] = bit; else if (i === 7) g[8][8] = bit;
|
||||
else if (i === 8) g[8][7] = bit; else g[8][14 - i] = bit;
|
||||
if (i < 8) g[8][size - 1 - i] = bit; else g[size - 15 + i][8] = bit;
|
||||
}
|
||||
const p = penalty(g, size);
|
||||
if (!best || p < best.p) best = { g, p, m };
|
||||
}
|
||||
return { size, version: v, ec, mask: best.m, modules: best.g.map((row) => Array.from(row, (x) => !!x)) };
|
||||
}
|
||||
|
||||
function penalty(g, n) {
|
||||
let p = 0;
|
||||
const runs = (get) => {
|
||||
for (let a = 0; a < n; a++) {
|
||||
let run = 1;
|
||||
for (let b = 1; b <= n; b++) {
|
||||
if (b < n && get(a, b) === get(a, b - 1)) run++;
|
||||
else { if (run >= 5) p += 3 + run - 5; run = 1; }
|
||||
}
|
||||
}
|
||||
};
|
||||
runs((a, b) => g[a][b]); runs((a, b) => g[b][a]);
|
||||
for (let r = 0; r < n - 1; r++) for (let c = 0; c < n - 1; c++) {
|
||||
const s = g[r][c] + g[r][c + 1] + g[r + 1][c] + g[r + 1][c + 1];
|
||||
if (s === 0 || s === 4) p += 3;
|
||||
}
|
||||
const pat = [1, 0, 1, 1, 1, 0, 1];
|
||||
const finderLike = (get, a) => {
|
||||
for (let b = 0; b <= n - 7; b++) {
|
||||
let ok = true;
|
||||
for (let k = 0; k < 7; k++) if (get(a, b + k) !== pat[k]) { ok = false; break; }
|
||||
if (!ok) continue;
|
||||
const before = b >= 4 && [0, 1, 2, 3].every((k) => get(a, b - 1 - k) === 0);
|
||||
const after = b + 10 < n && [0, 1, 2, 3].every((k) => get(a, b + 7 + k) === 0);
|
||||
if (before || after) p += 40;
|
||||
}
|
||||
};
|
||||
for (let a = 0; a < n; a++) { finderLike((x, y) => g[x][y], a); finderLike((x, y) => g[y][x], a); }
|
||||
let dark = 0;
|
||||
for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) dark += g[r][c];
|
||||
const pct = (dark * 100) / (n * n);
|
||||
p += Math.floor(Math.abs(pct - 50) / 5) * 10;
|
||||
return p;
|
||||
}
|
||||
|
||||
const api = { build };
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.QR = api;
|
||||
})(typeof globalThis !== "undefined" ? globalThis : this);
|
||||
506
wallet-inject.js
Normal file
506
wallet-inject.js
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
// Aegis dapp bridges. Runs in the isolated world of every matching tab and
|
||||
// puts up to two dapp APIs on the page's main world:
|
||||
//
|
||||
// window.bitcoincash Silent Mode's own BCH API (only on .x pages —
|
||||
// matches the pre-multi-wallet gate).
|
||||
// window.tronWeb TronLink-shaped Tron dapp bridge (on any page).
|
||||
// window.tronLink TronLink shim; `tron_requestAccounts` triggers
|
||||
// an approval overlay via activate().
|
||||
//
|
||||
// Everything crosses into the wallet's activate() context in main, which
|
||||
// shows the approval overlay and enforces per-origin permissions. Nothing
|
||||
// here can sign, read a balance, or read an address on its own.
|
||||
//
|
||||
// `theseus` is provided by the host: { id, origin, contextBridge, invoke }.
|
||||
|
||||
const call = (msg, payload) =>
|
||||
theseus.invoke(msg, payload).catch((e) => {
|
||||
const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
|
||||
throw new Error(text);
|
||||
});
|
||||
|
||||
// -------- BCH bridge (window.bitcoincash), only on .x pages ------------------
|
||||
const isBchOrigin = (() => { try { return /\.x$/i.test(location.hostname); } catch { return false; } })();
|
||||
if (isBchOrigin) {
|
||||
theseus.contextBridge.exposeInMainWorld("bitcoincash", {
|
||||
isTheseus: true,
|
||||
version: "0.2.0",
|
||||
network: "mainnet",
|
||||
getAddress: () => call("getAddress"),
|
||||
signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}),
|
||||
signMessage: (message) => call("signMessage", { message: String(message ?? "") }),
|
||||
});
|
||||
}
|
||||
|
||||
// -------- Tron bridge (window.tronWeb, window.tronLink), everywhere ---------
|
||||
//
|
||||
// Matches enough of the TronLink surface for read + send flows on dapps like
|
||||
// tronscan, sunswap, and justlend (mainnet or Nile). Not implemented:
|
||||
// TRC20/TRC721 abstractions, address utilities beyond base58<->hex, event
|
||||
// emitters beyond the "message" events TronLink documents.
|
||||
|
||||
let CACHED = { address: null, network: null };
|
||||
const listeners = new Set();
|
||||
|
||||
// Public copy of tronWeb.trx.
|
||||
const trx = {
|
||||
// Returns the account object TronGrid gives (may be {} for never-funded).
|
||||
// Deliberately mirrors tronWeb.trx.getAccount.
|
||||
getAccount: async () => call("trx.getAccount"),
|
||||
// Sign an assembled transaction (dapp built it via its own tronWeb).
|
||||
// Returns the same object with a `signature` field populated.
|
||||
sign: async (transaction) => {
|
||||
if (!transaction || typeof transaction !== "object" || !transaction.raw_data_hex) {
|
||||
throw new Error("tronWeb.trx.sign: expected an assembled transaction with raw_data_hex");
|
||||
}
|
||||
return call("trx.signTransaction", { transaction });
|
||||
},
|
||||
// Broadcast a signed transaction. Returns { result: true, txid } on success.
|
||||
sendRawTransaction: async (signedTx) => {
|
||||
const r = await call("trx.sendRawTransaction", { transaction: signedTx });
|
||||
return { result: true, txid: r.txid };
|
||||
},
|
||||
// Convenience wrappers matching the tronWeb surface — dapps call these
|
||||
// when the wallet is supposed to build the tx too. Left unimplemented
|
||||
// in this build so a dapp that hits them gets a clear error instead of
|
||||
// silently unassembled behavior; assemble on the dapp side.
|
||||
sendTrx: async () => { throw new Error("tronWeb.trx.sendTrx not implemented — build the transaction with tronWeb.transactionBuilder and call sign+sendRawTransaction"); },
|
||||
// TronLink documents signMessage (legacy, keccak of just the message) and
|
||||
// signMessageV2 (adds the "\x19TRON Signed Message:\n<len>" prefix). We
|
||||
// only implement V2 because that is what modern dapps use.
|
||||
signMessageV2: async (message) => {
|
||||
const r = await call("trx.signMessageV2", { message: String(message ?? "") });
|
||||
return r.signature;
|
||||
},
|
||||
};
|
||||
|
||||
// Shallow tronWeb — enough for `tronWeb.defaultAddress.base58` / `.hex` reads
|
||||
// and the trx methods. Populated once the user connects.
|
||||
const tronWeb = {
|
||||
ready: false,
|
||||
defaultAddress: { base58: false, hex: false, name: false },
|
||||
fullNode: { host: "" },
|
||||
solidityNode: { host: "" },
|
||||
eventServer: { host: "" },
|
||||
trx,
|
||||
// Utility helpers a few dapps poke at.
|
||||
isConnected: () => tronWeb.ready === true && !!tronWeb.defaultAddress.base58,
|
||||
address: {
|
||||
fromHex: (hex) => hex, // no-op stub; dapps that need a real
|
||||
toHex: (base58) => base58, // conversion should use their own copy.
|
||||
},
|
||||
// Some dapps read this to decide "Tron mainnet vs testnet".
|
||||
get currentNetwork() { return CACHED.network || null; },
|
||||
};
|
||||
|
||||
function nodeHost(network) {
|
||||
return network === "nile" ? "https://nile.trongrid.io" : "https://api.trongrid.io";
|
||||
}
|
||||
function applyConnection({ address, network }) {
|
||||
CACHED = { address, network };
|
||||
tronWeb.defaultAddress = { base58: address || false, hex: false, name: false };
|
||||
tronWeb.ready = !!address;
|
||||
tronLink.ready = !!address;
|
||||
const host = nodeHost(network);
|
||||
tronWeb.fullNode = { host };
|
||||
tronWeb.solidityNode = { host };
|
||||
tronWeb.eventServer = { host };
|
||||
// Broadcast on the window in the way TronLink documents.
|
||||
try {
|
||||
window.postMessage({ message: { action: "accountsChanged", data: { address } }, isTronLink: true }, location.origin);
|
||||
window.postMessage({ message: { action: "setAccount", data: { address } }, isTronLink: true }, location.origin);
|
||||
window.postMessage({ message: { action: "setNode", data: { node: { fullNode: host, solidityNode: host, eventServer: host, chain: network === "nile" ? "0xcd8690dc" : "0x2b6653dc" } } }, isTronLink: true }, location.origin);
|
||||
} catch {}
|
||||
for (const fn of listeners) { try { fn({ address, network }); } catch {} }
|
||||
}
|
||||
|
||||
const tronLink = {
|
||||
ready: false,
|
||||
tronWeb: tronWeb,
|
||||
// TronLink's canonical request router. Handles at minimum tron_requestAccounts.
|
||||
request: async (opts) => {
|
||||
const method = String(opts?.method || "");
|
||||
if (method === "tron_requestAccounts" || method === "requestAccounts") {
|
||||
const r = await call("trx.requestAccounts");
|
||||
applyConnection({ address: r.address, network: r.network });
|
||||
return { code: 200, message: "ok" };
|
||||
}
|
||||
if (method === "tron_accounts") {
|
||||
const r = await call("trx.getAccount").catch(() => null);
|
||||
return r ? [r.address] : [];
|
||||
}
|
||||
throw new Error("Aegis: unsupported tronLink method " + method);
|
||||
},
|
||||
// Dapps sometimes attach here to react to wallet changes.
|
||||
on: (event, fn) => {
|
||||
if (event === "message" || event === "accountsChanged" || event === "networkChanged") listeners.add(fn);
|
||||
},
|
||||
removeListener: (_event, fn) => listeners.delete(fn),
|
||||
};
|
||||
|
||||
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
|
||||
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
|
||||
|
||||
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
|
||||
//
|
||||
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
|
||||
// object to touch the dapp's own JS values — Solana in particular passes
|
||||
// `Transaction` instances whose `.serializeMessage()` method the wallet has
|
||||
// to call. Electron's contextBridge shallow-copies function arguments
|
||||
// between worlds and strips methods, so bridges that need to call methods
|
||||
// on dapp-side objects have to live in the main world. We inject a `<script>`
|
||||
// with the bridge source; it runs synchronously in the main world and talks
|
||||
// to us via `window.postMessage` on a namespaced envelope. This mirrors how
|
||||
// MetaMask and Phantom bridge extension code back to page code.
|
||||
|
||||
// Namespace used on the postMessage envelope. Includes the addon id so a
|
||||
// page that runs multiple dapp-wallet extensions doesn't misroute messages.
|
||||
const AEGIS_TAG = "aegis-" + theseus.id;
|
||||
const pendingCalls = new Map();
|
||||
window.addEventListener("message", async (e) => {
|
||||
const d = e && e.data;
|
||||
if (!d || d.aegisTag !== AEGIS_TAG) return;
|
||||
if (d.kind === "request") {
|
||||
// Forward main-world → isolated-world → addon.
|
||||
try {
|
||||
const result = await call(d.msg, d.payload);
|
||||
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: true, result }, location.origin);
|
||||
} catch (err) {
|
||||
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: false, error: String(err && err.message || err) }, location.origin);
|
||||
}
|
||||
}
|
||||
});
|
||||
function emitToMainWorld(event, data) {
|
||||
try { window.postMessage({ aegisTag: AEGIS_TAG, kind: "event", event, data }, location.origin); } catch {}
|
||||
}
|
||||
|
||||
// The main-world bridge — installed as a page-level `<script>` so it can
|
||||
// call methods on Transaction objects the dapp hands it, and so the globals
|
||||
// it defines look like ordinary page code to the dapp.
|
||||
const mainWorldSource = `(function () {
|
||||
if (window.__aegisBridge) return;
|
||||
window.__aegisBridge = true;
|
||||
const TAG = ${JSON.stringify(AEGIS_TAG)};
|
||||
const pending = new Map();
|
||||
let seq = 1;
|
||||
function invoke(msg, payload) {
|
||||
const id = "r" + (seq++);
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
window.postMessage({ aegisTag: TAG, kind: "request", id, msg, payload }, location.origin);
|
||||
});
|
||||
}
|
||||
window.addEventListener("message", (e) => {
|
||||
const d = e && e.data;
|
||||
if (!d || d.aegisTag !== TAG) return;
|
||||
if (d.kind === "response") {
|
||||
const p = pending.get(d.id);
|
||||
if (!p) return;
|
||||
pending.delete(d.id);
|
||||
d.ok ? p.resolve(d.result) : p.reject(new Error(d.error));
|
||||
} else if (d.kind === "event") {
|
||||
dispatchEvent(d.event, d.data);
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Ethereum (EIP-1193) ------------------------------------------------
|
||||
const ethListeners = { connect: [], disconnect: [], accountsChanged: [], chainChanged: [], message: [] };
|
||||
let ethState = { address: null, chainIdHex: "0x1", networkVersion: "1" };
|
||||
function ethEmit(event, data) {
|
||||
for (const fn of ethListeners[event] || []) { try { fn(data); } catch {} }
|
||||
}
|
||||
// After a chain switch or add, refresh the local chainId/address state
|
||||
// and fire the two events dapps expect (accountsChanged +
|
||||
// chainChanged). MetaMask does the same round-trip after switchChain.
|
||||
async function pullEthStateAndEmit() {
|
||||
try {
|
||||
const s = await invoke("eth.state");
|
||||
const oldChain = ethState.chainIdHex, oldAddr = ethState.address;
|
||||
ethState.address = s.address || null;
|
||||
ethState.chainIdHex = s.chainIdHex;
|
||||
ethState.networkVersion = s.networkVersion;
|
||||
window.ethereum.selectedAddress = ethState.address;
|
||||
window.ethereum.chainId = ethState.chainIdHex;
|
||||
window.ethereum.networkVersion = ethState.networkVersion;
|
||||
if (s.chainIdHex !== oldChain) ethEmit("chainChanged", s.chainIdHex);
|
||||
if ((s.address || null) !== oldAddr) ethEmit("accountsChanged", s.address ? [s.address] : []);
|
||||
} catch {}
|
||||
}
|
||||
async function ethHandle(method, params) {
|
||||
params = Array.isArray(params) ? params : (params ? [params] : []);
|
||||
switch (method) {
|
||||
case "eth_requestAccounts": {
|
||||
const r = await invoke("eth.requestAccounts");
|
||||
ethState.address = r.address;
|
||||
ethState.chainIdHex = r.chainIdHex;
|
||||
ethState.networkVersion = r.networkVersion;
|
||||
window.ethereum.selectedAddress = r.address;
|
||||
window.ethereum.chainId = r.chainIdHex;
|
||||
window.ethereum.networkVersion = r.networkVersion;
|
||||
ethEmit("accountsChanged", [r.address]);
|
||||
return [r.address];
|
||||
}
|
||||
case "eth_accounts":
|
||||
return ethState.address ? [ethState.address] : [];
|
||||
case "eth_chainId":
|
||||
return ethState.chainIdHex;
|
||||
case "net_version":
|
||||
return ethState.networkVersion;
|
||||
case "personal_sign": {
|
||||
// Both param orders are seen in the wild: [message, from] and [from, message].
|
||||
const [a, b] = params;
|
||||
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
|
||||
const message = looksLikeAddr(a) ? b : a;
|
||||
return (await invoke("eth.personalSign", { message: String(message) })).signature;
|
||||
}
|
||||
case "eth_sign": {
|
||||
// Legacy method. Same shape as personal_sign for our purposes.
|
||||
const [_from, msg] = params;
|
||||
return (await invoke("eth.personalSign", { message: String(msg) })).signature;
|
||||
}
|
||||
case "eth_sendTransaction": {
|
||||
const tx = params[0] || {};
|
||||
return (await invoke("eth.sendTransaction", { tx })).txid;
|
||||
}
|
||||
case "eth_signTypedData_v4":
|
||||
case "eth_signTypedData":
|
||||
case "eth_signTypedData_v3": {
|
||||
// v3/v4 differ mostly in nested-struct support; the encoder handles
|
||||
// both. v1 is the flat "type[]" schema that Metamask deprecated —
|
||||
// reject it, dapps that still use v1 should upgrade.
|
||||
const [a, b] = params;
|
||||
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
|
||||
const typedData = looksLikeAddr(a) ? b : a;
|
||||
return (await invoke("eth.signTypedData", { typedData })).signature;
|
||||
}
|
||||
case "wallet_switchEthereumChain": {
|
||||
const target = String((params[0] && params[0].chainId) || "").toLowerCase();
|
||||
try {
|
||||
const r = await invoke("eth.switchChain", { chainId: target });
|
||||
await pullEthStateAndEmit();
|
||||
return r;
|
||||
}
|
||||
catch (e) {
|
||||
// EIP-3326: preserve the 4902 signal the isolated-world handler
|
||||
// stamps on the Error so dapps fall through to addChain.
|
||||
if (/is not added/i.test(e.message || "")) { const err = new Error(e.message); err.code = 4902; throw err; }
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
case "wallet_addEthereumChain": {
|
||||
// EIP-3085. Approval overlay + persistence live in the addon.
|
||||
const r = await invoke("eth.addChain", { params: params[0] });
|
||||
await pullEthStateAndEmit();
|
||||
return r;
|
||||
}
|
||||
case "wallet_getPermissions":
|
||||
case "wallet_requestPermissions":
|
||||
// Minimum shape most dapps accept.
|
||||
return [{ parentCapability: "eth_accounts" }];
|
||||
default:
|
||||
// Read passthrough: eth_getBalance, eth_call, eth_blockNumber, etc.
|
||||
return invoke("eth.rpc", { method, params });
|
||||
}
|
||||
}
|
||||
const ethereum = {
|
||||
isAegis: true,
|
||||
isMetaMask: false,
|
||||
chainId: ethState.chainIdHex,
|
||||
networkVersion: ethState.networkVersion,
|
||||
selectedAddress: null,
|
||||
request: (opts) => ethHandle(String(opts && opts.method || ""), (opts && opts.params) || []),
|
||||
// EIP-1193 event surface.
|
||||
on: (event, fn) => { if (ethListeners[event]) ethListeners[event].push(fn); },
|
||||
removeListener: (event, fn) => {
|
||||
const arr = ethListeners[event]; if (!arr) return;
|
||||
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
|
||||
},
|
||||
// Legacy compat some old dapps still call.
|
||||
enable: () => ethereum.request({ method: "eth_requestAccounts" }),
|
||||
sendAsync: (payload, cb) => ethereum.request(payload).then((result) => cb(null, { id: payload.id, jsonrpc: "2.0", result }), (err) => cb(err)),
|
||||
send: (methodOrPayload, params) => ethereum.request(typeof methodOrPayload === "string" ? { method: methodOrPayload, params } : methodOrPayload),
|
||||
};
|
||||
|
||||
// ---- Solana (wallet-adapter shape) --------------------------------------
|
||||
const solListeners = { connect: [], disconnect: [], accountChanged: [] };
|
||||
let solState = { publicKey: null };
|
||||
function makePubkey(base58) {
|
||||
return {
|
||||
toString: () => base58,
|
||||
toBase58: () => base58,
|
||||
toBytes: () => base58ToBytes(base58),
|
||||
equals: (other) => other && other.toString && other.toString() === base58,
|
||||
_bn: null,
|
||||
};
|
||||
}
|
||||
// Local base58 decoder — needed to expose PublicKey.toBytes(). Alphabet
|
||||
// matches Bitcoin's (the only base58 flavor in real use).
|
||||
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
||||
function base58ToBytes(s) {
|
||||
let zeros = 0;
|
||||
while (zeros < s.length && s[zeros] === B58[0]) zeros++;
|
||||
const out = new Uint8Array(Math.ceil(s.length * 733 / 1000 + 1));
|
||||
let length = 0;
|
||||
for (let i = zeros; i < s.length; i++) {
|
||||
const v = B58.indexOf(s[i]);
|
||||
if (v < 0) throw new Error("bad base58 char " + s[i]);
|
||||
let carry = v, j = 0;
|
||||
for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
|
||||
carry += 58 * out[k];
|
||||
out[k] = carry & 0xff;
|
||||
carry >>>= 8;
|
||||
}
|
||||
length = j;
|
||||
}
|
||||
let it = out.length - length;
|
||||
while (it < out.length && out[it] === 0) it++;
|
||||
const total = zeros + (out.length - it);
|
||||
const dec = new Uint8Array(total);
|
||||
let p = zeros;
|
||||
while (it < out.length) dec[p++] = out[it++];
|
||||
return dec;
|
||||
}
|
||||
function bytesToBase58(b) {
|
||||
let zeros = 0;
|
||||
while (zeros < b.length && b[zeros] === 0) zeros++;
|
||||
const buf = new Uint8Array(Math.ceil(b.length * 138 / 100 + 1));
|
||||
let length = 0;
|
||||
for (let i = zeros; i < b.length; i++) {
|
||||
let carry = b[i], j = 0;
|
||||
for (let k = buf.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
|
||||
carry += (buf[k] << 8) >>> 0;
|
||||
buf[k] = carry % 58;
|
||||
carry = (carry / 58) | 0;
|
||||
}
|
||||
length = j;
|
||||
}
|
||||
let it = buf.length - length;
|
||||
while (it < buf.length && buf[it] === 0) it++;
|
||||
let out = "";
|
||||
for (let i = 0; i < zeros; i++) out += B58[0];
|
||||
for (; it < buf.length; it++) out += B58[buf[it]];
|
||||
return out;
|
||||
}
|
||||
function u8ToBase64(u8) {
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
|
||||
return btoa(s);
|
||||
}
|
||||
function base64ToU8(s) {
|
||||
const bin = atob(s);
|
||||
const u8 = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||||
return u8;
|
||||
}
|
||||
async function solConnect() {
|
||||
const r = await invoke("sol.connect");
|
||||
solState.publicKey = makePubkey(r.address);
|
||||
window.solana.publicKey = solState.publicKey;
|
||||
window.solana.isConnected = true;
|
||||
for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} }
|
||||
return { publicKey: solState.publicKey };
|
||||
}
|
||||
async function solDisconnect() {
|
||||
solState.publicKey = null;
|
||||
window.solana.publicKey = null;
|
||||
window.solana.isConnected = false;
|
||||
for (const fn of solListeners.disconnect) { try { fn(); } catch {} }
|
||||
}
|
||||
async function solSignMessage(u8) {
|
||||
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []);
|
||||
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) });
|
||||
return { publicKey: solState.publicKey, signature: base58ToBytes(r.signature) };
|
||||
}
|
||||
// Solana Transaction objects have serializeMessage() → Uint8Array and
|
||||
// addSignature(publicKey, sig) — we live in the main world, so we can
|
||||
// call both. Wallets that live in an isolated content-script can't.
|
||||
async function solSignAndSendTransaction(tx, opts) {
|
||||
if (!tx || typeof tx.serialize !== "function") {
|
||||
throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serialize)");
|
||||
}
|
||||
// Serialize the FULL wire including any partial signatures the dapp
|
||||
// has already collected (multi-signer flows: session keys, escrows,
|
||||
// ephemeral co-signers). Aegis fills the wallet's own signature slot
|
||||
// in the addon and leaves the other slots untouched.
|
||||
const wire = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
|
||||
const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(new Uint8Array(wire)) });
|
||||
return { signature: r.txid, publicKey: solState.publicKey };
|
||||
}
|
||||
async function solSignTransaction(tx) {
|
||||
if (!tx || typeof tx.serializeMessage !== "function" || typeof tx.addSignature !== "function") {
|
||||
throw new Error("Aegis: pass a @solana/web3.js Transaction");
|
||||
}
|
||||
const messageBytes = tx.serializeMessage();
|
||||
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) });
|
||||
// r.signature is base58 of the 64-byte ed25519 sig.
|
||||
tx.addSignature(solState.publicKey, base58ToBytes(r.signature));
|
||||
return tx;
|
||||
}
|
||||
const solana = {
|
||||
isAegis: true,
|
||||
isPhantom: true, // set so dapps that gate on isPhantom pick us
|
||||
isConnected: false,
|
||||
publicKey: null,
|
||||
connect: async (opts) => solConnect(opts),
|
||||
disconnect: solDisconnect,
|
||||
signMessage: (u8) => solSignMessage(u8),
|
||||
signTransaction: (tx) => solSignTransaction(tx),
|
||||
signAllTransactions: async (txs) => {
|
||||
const out = [];
|
||||
for (const tx of txs) out.push(await solSignTransaction(tx));
|
||||
return out;
|
||||
},
|
||||
signAndSendTransaction: (tx, opts) => solSignAndSendTransaction(tx, opts),
|
||||
request: async (opts) => {
|
||||
const method = String(opts && opts.method || "");
|
||||
const params = opts && opts.params || {};
|
||||
if (method === "connect") return solConnect();
|
||||
if (method === "disconnect") return solDisconnect();
|
||||
if (method === "signMessage") return solSignMessage(params.message);
|
||||
if (method === "signTransaction") return solSignTransaction(params.transaction);
|
||||
if (method === "signAndSendTransaction") return solSignAndSendTransaction(params.transaction, params.options);
|
||||
throw new Error("Aegis: unsupported solana method " + method);
|
||||
},
|
||||
on: (event, fn) => { if (solListeners[event]) solListeners[event].push(fn); },
|
||||
off: (event, fn) => {
|
||||
const arr = solListeners[event]; if (!arr) return;
|
||||
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
|
||||
},
|
||||
removeAllListeners: () => { Object.keys(solListeners).forEach((k) => solListeners[k].length = 0); },
|
||||
};
|
||||
|
||||
function dispatchEvent(event, data) {
|
||||
if (event === "eth.accountsChanged") { ethState.address = data.address || null; ethereum.selectedAddress = ethState.address; ethEmit("accountsChanged", data.address ? [data.address] : []); }
|
||||
else if (event === "eth.chainChanged") { ethState.chainIdHex = data.chainIdHex; ethState.networkVersion = data.networkVersion; ethereum.chainId = data.chainIdHex; ethereum.networkVersion = data.networkVersion; ethEmit("chainChanged", data.chainIdHex); }
|
||||
else if (event === "sol.accountChanged") {
|
||||
const pk = data.address ? makePubkey(data.address) : null;
|
||||
solState.publicKey = pk; solana.publicKey = pk;
|
||||
for (const fn of solListeners.accountChanged || []) { try { fn(pk); } catch {} }
|
||||
}
|
||||
}
|
||||
|
||||
// Install the globals. Defined lazily via Object.defineProperty so we
|
||||
// survive dapps that check hasOwnProperty(window, "ethereum") after page
|
||||
// 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, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; }
|
||||
// EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis.
|
||||
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 announce = () => window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider: ethereum }) }));
|
||||
announce();
|
||||
window.addEventListener("eip6963:requestProvider", announce);
|
||||
} catch {}
|
||||
})();`;
|
||||
|
||||
// Actually push the script into the main world. Doing this at
|
||||
// document_start (which is when this preload runs) means the bridge is in
|
||||
// place before the dapp's own scripts execute.
|
||||
try {
|
||||
const s = document.createElement("script");
|
||||
s.textContent = mainWorldSource;
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
s.remove();
|
||||
} catch (e) {
|
||||
console.warn("[aegis] main-world bridge install failed:", e && e.message || e);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue