Merge Aegis 0.28.1: bundle the wallet users already get over the air

Fresh installs started on Aegis 0.9.0 and froze on large stores until the
OTA caught up. Bundling 0.28.1 makes the first launch the fixed one.
This commit is contained in:
Local Dev 2026-10-03 21:07:11 +02:00
commit e65c5bea52
22 changed files with 13805 additions and 622 deletions

View file

@ -1,17 +1,32 @@
{
"id": "aegis",
"name": "Aegis Wallet",
"version": "0.9.0",
"version": "0.28.1",
"category": "plugin",
"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.",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash and window.wizardconnect on any site; window.tronWeb / window.tronLink / window.ethereum / window.solana too. Every call needs your approval.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E",
"main": "index.js",
"updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json",
"capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal", "scan-page"],
"absorbs": ["bchwallet", "siawallet"],
"capabilities": [
"sidebar-panel",
"vault-derive",
"page-inject",
"approval-modal",
"scan-page",
"open-tab"
],
"absorbs": [
"bchwallet",
"siawallet"
],
"page-inject": {
"preload": "wallet-inject.js",
"origins": ["https://*/*"]
"origins": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://[::1]/*"
]
}
}

File diff suppressed because it is too large Load diff

View file

@ -9,10 +9,16 @@
// 1. HTTPS URL configured per-user in Settings ("registry endpoints").
// The registry publishes a compact JSON with keyed identities;
// lookup by category is O(1).
// 2. Static bundled fallback (registries/) for a handful of well-known
// tokens (Cauldron, Fex.cash, TapSwap, ParyonUSD). Ships in the
// addon so brand-new users see names on the first launch even
// before they configure a live registry.
// 2. A local name the user sets themselves, stored under
// "bcmr/local/<categoryHex>" and taking precedence over any registry.
// Self-minted tokens — anything on chipnet, anything from a project
// that keeps its own names client-side — publish no metadata at all:
// no registry entry, and often not even an OP_RETURN in the genesis
// transaction, so there is nowhere for a wallet to look. A local label
// is the only thing that can name those.
//
// (An earlier comment here promised a bundled static fallback for well-known
// tokens. There is no such directory and never was; it is not a load path.)
//
// Cache is on-disk via api.storage under "bcmr/<categoryHex>" =
// { snapshot, fetchedAt, source }. A metadata refresh runs at most once
@ -26,9 +32,15 @@ const REFRESH_MIN_MS = 6 * 60 * 60 * 1000; // 6 hours
// Well-known registries seeded on first run so a fresh wallet doesn't need
// any configuration to see names for the top BCH tokens. Users can add /
// remove entries in Settings.
// Both of the previous defaults were dead — checked 2026-09-29:
// raw.githubusercontent.com/cashonize/registry/main/bcmr.json -> 404
// bcmr.salemkode.com/registry.json -> DNS fail
// So NO token ever resolved a name, on any chain, and the panel's
// .catch(() => {}) meant the failure was completely silent. Anything added
// here should be re-checked rather than trusted; a registry that 404s is
// indistinguishable from a token nobody has registered.
const DEFAULT_REGISTRIES = [
{ id: "cashonize", label: "Cashonize registry", url: "https://raw.githubusercontent.com/cashonize/registry/main/bcmr.json" },
{ id: "salemkode", label: "SalemKode registry", url: "https://bcmr.salemkode.com/registry.json" },
{ id: "otr", label: "OpenTokenRegistry", url: "https://otr.cash/.well-known/bitcoin-cash-metadata-registry.json" },
];
module.exports = function makeBcmr({ storage, log = () => {} }) {
@ -124,23 +136,56 @@ module.exports = function makeBcmr({ storage, log = () => {} }) {
return storage.get(`bcmr/${categoryHex}`, null);
}
// ---- local names ---------------------------------------------------------
// A name the user typed for a category no registry knows about. Kept
// separate from the BCMR cache so a later registry fetch cannot clobber it,
// and so clearing it falls back to whatever the registry says.
function localName(categoryHex) {
const v = storage.get(`bcmr/local/${categoryHex}`, null);
return v && (v.name || v.symbol) ? v : null;
}
function setLocalName(categoryHex, { name, symbol, decimals } = {}) {
const key = `bcmr/local/${categoryHex}`;
const n = String(name || "").trim().slice(0, 40);
const s = String(symbol || "").trim().slice(0, 12);
const d = Number(decimals);
if (!n && !s) { storage.set(key, null); return null; }
const rec = { name: n || null, symbol: s || null };
if (Number.isFinite(d) && d >= 0 && d <= 18) rec.decimals = Math.floor(d);
storage.set(key, rec);
return rec;
}
// Compact metadata slice the panel wants: { name, symbol, description,
// decimals, iconUri }. Handles both the top-level identity fields and
// the token subobject (BCMR v2 puts token-specific data there).
function metadataOf(entry) {
if (!entry || !entry.snapshot) return null;
//
// A local name wins over the registry: the user typed it for this exact
// category, which is better evidence than a third-party document.
function metadataOf(entry, categoryHex) {
const local = categoryHex ? localName(categoryHex) : null;
if (!entry || !entry.snapshot) {
return local
? { name: local.name, symbol: local.symbol, description: null,
decimals: Number.isFinite(local.decimals) ? local.decimals : 0,
iconUri: null, source: "local", local: true }
: null;
}
const s = entry.snapshot;
const t = s.token || {};
return {
name: s.name || t.name || null,
symbol: s.token?.symbol || s.symbol || null,
name: local?.name || s.name || t.name || null,
symbol: local?.symbol || s.token?.symbol || s.symbol || null,
description: s.description || null,
decimals: Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0,
decimals: Number.isFinite(local?.decimals) ? local.decimals
: (Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0),
// Icon URIs live under s.uris.icon per schema; older files use s.icon.
iconUri: s.uris?.icon || s.icon || null,
source: entry.source || null,
source: local ? "local" : (entry.source || null),
local: !!local,
};
}
return { lookup, lookupMany, cached, metadataOf, registryList, setRegistries, DEFAULT_REGISTRIES };
return { lookup, lookupMany, cached, metadataOf, localName, setLocalName,
registryList, setRegistries, DEFAULT_REGISTRIES };
};

View file

@ -35,8 +35,14 @@ module.exports = function makeImportedBchAdapter({
},
chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/",
// chipnet.imaginary.cash's WEB explorer has been returning 502 for
// weeks, so every history row and Explorer button on an imported
// chipnet wallet led to a dead page. chain-bch.js was moved to our own
// explorer and this adapter was missed — which is most of them, since
// a keystore bulk import produces imported wallets.
// Its Electrum endpoint on :50004 is unrelated and still in use below.
explorerTx: "https://aegis.x/explorer/chipnet/?tx=",
explorerAddr: "https://aegis.x/explorer/chipnet/?addr=",
defaultServers: [
"wss://chipnet.imaginary.cash:50004",
"wss://chipnet.bch.ninja:50004",
@ -120,6 +126,7 @@ module.exports = function makeImportedBchAdapter({
history: [],
utxos: [],
height: 0,
tokenBalances: null, // { <categoryHex>: {fungible, nfts, utxoCount} }
scanning: false,
error: null,
};
@ -148,6 +155,9 @@ module.exports = function makeImportedBchAdapter({
balance: this._state.balance,
height: this._state.height,
history: this._state.history,
// Without this the panel's Assets card has nothing to read and stays
// hidden however many tokens refresh() found.
tokenBalances: this._state.tokenBalances || null,
scanning: this._state.scanning,
error: this._state.error,
server: this._client.url || null,
@ -170,9 +180,46 @@ module.exports = function makeImportedBchAdapter({
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
// Always pull UTXOs so spend / send-max work off fresh state.
const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]);
this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({
const list = Array.isArray(utxos) ? utxos : [];
this._state.utxos = list.map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
token: u.token_data || null,
}));
// CashTokens. This adapter never looked for them, so a WIF-imported
// wallet holding tokens reported none and the panel hid its Assets
// card — a chipnet test wallet with 46 categories showed nothing.
//
// The HD path (lib/wallet.js) decodes the token prefix off each
// UTXO's scriptPubKey, which costs one transaction fetch per UTXO.
// Here we take the server's own `token_data` instead: one call for
// the whole set. That arrives only when protocol >= 1.5 was
// negotiated (see electrum.js), and when it does not, tokens are
// simply absent — the same as before this change, never wrong.
const tokenBalances = {};
for (const u of list) {
const t = u.token_data;
if (!t || !t.category) continue;
const cat = String(t.category);
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoCount: 0 };
const b = tokenBalances[cat];
b.utxoCount++;
if (t.amount) { try { b.fungible += BigInt(t.amount); } catch (_e) {} }
if (t.nft) {
b.nfts.push({
utxoId: `${u.tx_hash}:${u.tx_pos}`,
commitmentHex: t.nft.commitment || "",
capability: t.nft.capability || "none",
capabilityLabel: t.nft.capability || "none",
});
}
}
// Same serialised shape the HD wallet emits — fungible as a decimal
// string, since JSON.stringify cannot carry a BigInt.
const serialised = {};
for (const [cat, b] of Object.entries(tokenBalances)) {
serialised[cat] = { fungible: b.fungible.toString(), nfts: b.nfts, utxoCount: b.utxoCount };
}
this._state.tokenBalances = serialised;
if (full) {
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({

View file

@ -28,8 +28,12 @@ const BCH_NETWORKS = {
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/",
// chipnet.imaginary.cash's WEB explorer has been returning 502 (its
// Electrum endpoint on :50004 is unaffected and still serves balances).
// Point at our own instead — same chain data, read over the same
// Electrum connection, and a page we can fix when it breaks.
explorerTx: "https://aegis.x/explorer/chipnet/?tx=",
explorerAddr: "https://aegis.x/explorer/chipnet/?addr=",
defaultServers: [
"wss://chipnet.imaginary.cash:50004",
"wss://chipnet.bch.ninja:50004",

View file

@ -150,7 +150,45 @@ module.exports = function makeGenericImportedAdapter() {
const j = await r.json();
return String(j?.balance || 0);
},
async fetchHistory({ rpc, address }) {
// Native TRX transfers AND TRC20 token transfers, merged newest-first
// and each tagged with `asset` so the History tab can filter. Token
// movement is invisible in the native feed — a wallet that only ever
// moved USDT looked like it had no history at all.
async fetchHistory(opts) {
const [native, tokens] = await Promise.all([
CHAIN_CFGS.trx._fetchNativeHistory(opts),
CHAIN_CFGS.trx._fetchTokenHistory(opts).catch(() => []),
]);
return [...native, ...tokens]
.sort((a, b) => (b.time || 0) - (a.time || 0))
.slice(0, 40);
},
async _fetchTokenHistory({ rpc, address }) {
const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=25`);
if (!r.ok) throw new Error(`Tron trc20 history HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j?.data) ? j.data : [];
const me = String(address);
return list.map((t) => {
const ti = t.token_info || {};
const outgoing = String(t.from || "") === me;
const raw = String(t.value || "0");
return {
txid: t.transaction_id,
time: Math.floor(Number(t.block_timestamp || 0) / 1000),
confirmations: 1,
status: "confirmed",
// Token amounts are in the TOKEN's own decimals, not the
// chain's, so they travel with their own scale rather than
// being rendered against the native one.
delta: (outgoing ? "-" : "") + raw,
assetDecimals: Number.isFinite(Number(ti.decimals)) ? Number(ti.decimals) : 0,
asset: cleanTokenText(ti.symbol) || "token",
kind: "TRC20",
};
}).filter((t) => t.txid);
},
async _fetchNativeHistory({ rpc, address }) {
const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`);
if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`);
const j = await r.json();
@ -171,6 +209,7 @@ module.exports = function makeGenericImportedAdapter() {
status: ok ? "confirmed" : "failed",
// Aegis renders `delta` in the wallet's base unit (sun here).
delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0,
asset: null, // null = the chain's native coin
kind: c?.type || "Contract",
};
}).filter((t) => t.txid);
@ -301,7 +340,10 @@ module.exports = function makeGenericImportedAdapter() {
this.chain = chain;
this.network = network;
this._cfg = cfg;
this._net = { ...net, rpc: rpcUrl || net.rpc };
// A custom RPC must look like one; anything else (empty, "undefined",
// a stray non-URL) falls back to the network default.
const customRpc = typeof rpcUrl === "string" && /^https?:\/\/\S+$/i.test(rpcUrl.trim()) ? rpcUrl.trim() : null;
this._net = { ...net, rpc: customRpc || net.rpc };
this.log = log;
this.onChange = onChange;
this._address = address;

View file

@ -1,10 +1,7 @@
import { payments, initEccLib } from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import { payments, ensureEcc } from '../deps.js';
import { digibyte, digibyteLegacyP2SH } from './network.js';
// Required for Taproot address derivation (P2TR).
initEccLib(ecc);
export function p2pkhAddress(node, network = digibyte) {
const { address } = payments.p2pkh({
const { address } = payments().p2pkh({
pubkey: Buffer.from(node.publicKey),
network,
});
@ -13,11 +10,11 @@ export function p2pkhAddress(node, network = digibyte) {
return address;
}
export function p2shP2wpkhAddress(node, network = digibyte) {
const redeem = payments.p2wpkh({
const redeem = payments().p2wpkh({
pubkey: Buffer.from(node.publicKey),
network,
});
const { address } = payments.p2sh({ redeem, network });
const { address } = payments().p2sh({ redeem, network });
if (!address)
throw new Error('p2sh-p2wpkh derivation returned no address');
return address;
@ -33,7 +30,7 @@ export function p2shP2wpkhAddressesBoth(node) {
};
}
export function p2wpkhAddress(node, network = digibyte) {
const { address } = payments.p2wpkh({
const { address } = payments().p2wpkh({
pubkey: Buffer.from(node.publicKey),
network,
});
@ -44,8 +41,9 @@ export function p2wpkhAddress(node, network = digibyte) {
// 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) {
ensureEcc(); // Taproot needs initEccLib; done on demand, once.
const internalPubkey = Buffer.from(node.publicKey.subarray(1, 33));
const { address } = payments.p2tr({ internalPubkey, network });
const { address } = payments().p2tr({ internalPubkey, network });
if (!address)
throw new Error('p2tr derivation returned no address');
return address;

View file

@ -1,15 +1,23 @@
import { BIP32Factory } from 'bip32';
import * as ecc from '@bitcoinerlab/secp256k1';
import { bip32, hmac, sha512 } from '../deps.js';
import { digibyte, DGB_COIN_TYPE } from './network.js';
const bip32 = BIP32Factory(ecc);
// BIP32 derives the master key as HMAC-SHA512(key, seed) with the key
// "Bitcoin seed". DigiByte's 2018-19 official mobile wallets (BreadWallet
// forks) used "DigiByte seed" instead, so the SAME 12 words give a
// completely different key tree — every address differs. A recovery that
// only tries the standard key never finds those coins.
// Ported from Digibyte.X/dgb-wallet @ 989a2696.
export const HMAC_BITCOIN_SEED = 'Bitcoin seed';
export const HMAC_DIGIBYTE_SEED = 'DigiByte seed';
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);
export function rootFromSeed(seed, network = digibyte, hmacKey = HMAC_BITCOIN_SEED) {
if (hmacKey === HMAC_BITCOIN_SEED) return bip32().fromSeed(seed, network);
const I = hmac()(sha512(), new TextEncoder().encode(hmacKey), seed);
return bip32().fromPrivateKey(Buffer.from(I.slice(0, 32)), Buffer.from(I.slice(32)), network);
}
// Standard account-level derivation: m/purpose'/coin'/account'.
// account defaults to 0 (the first account).

View file

@ -1,17 +1,17 @@
import { generateMnemonic, validateMnemonic, mnemonicToSeed, wordlists, } from 'bip39';
import { bip39 } from '../deps.js';
// Word count → entropy strength for BIP39.
// 12 → 128, 15 → 160, 18 → 192, 21 → 224, 24 → 256.
export function generateSeedPhrase(strength = 128) {
return generateMnemonic(strength);
return bip39().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);
export function validateSeedPhrase(phrase, wordlist = undefined) {
return bip39().validateMnemonic(phrase.trim(), wordlist || bip39().wordlists.english);
}
// 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);
return bip39().mnemonicToSeed(phrase.trim(), passphrase);
}
//# sourceMappingURL=seed.js.map

View file

@ -1,9 +1,5 @@
import { ECPairFactory } from 'ecpair';
import * as ecc from '@bitcoinerlab/secp256k1';
import { payments, initEccLib } from 'bitcoinjs-lib';
import { payments, ECPair, ensureEcc } from '../deps.js';
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
@ -11,13 +7,13 @@ initEccLib(ecc);
export function importWif(wif) {
const trimmed = wif.trim();
try {
return { keyPair: ECPair.fromWIF(trimmed, digibyte), variant: 'modern' };
return { keyPair: ECPair().fromWIF(trimmed, digibyte), variant: 'modern' };
}
catch {
// fall through to legacy attempt
}
try {
return { keyPair: ECPair.fromWIF(trimmed, digibyteLegacyWIF), variant: 'legacy' };
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). ` +
@ -26,23 +22,24 @@ export function importWif(wif) {
}
// Import against a specific network only (advanced / testing).
export function importWifStrict(wif, network = digibyte) {
return ECPair.fromWIF(wif.trim(), network);
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 });
ensureEcc(); // p2tr below needs initEccLib.
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 });
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');
}

View file

@ -0,0 +1,62 @@
// Dependency injection for the vendored DGB modules.
//
// These files were vendored from @dgb-wallet/core and imported
// "bitcoinjs-lib", "bip32", "ecpair" and "@bitcoinerlab/secp256k1" as bare
// specifiers. That resolves fine in a dev checkout, where Theseus's
// node_modules sits above the add-on — and fails for every OTA install, where
// Aegis lives in <userData>/extensions/aegis/ and Node's ESM resolver has
// nowhere to find them. index.js caught the failure and set dgbCore = null,
// so DigiByte quietly stopped existing and an import died with "DGB adapter
// not available". It worked on a fresh install and broke after the first
// update, which is the worst possible shape for a bug.
//
// index.js already holds all of these via api.require (which resolves against
// the app tree), so it injects them here before importing anything under
// lib/dgb/. Every consumer reads them through the accessors below rather than
// capturing them at module scope, so nothing depends on import order and a
// missing injection fails loudly at the call instead of silently at load.
let deps = null;
export function setDgbDeps(d) {
const need = ["bitcoinjs", "ecc", "bip32Factory", "ecpairFactory", "bip39", "hmac", "sha512"];
for (const k of need) {
if (!d || !d[k]) throw new Error(`setDgbDeps: missing ${k}`);
}
deps = d;
}
function need() {
if (!deps) throw new Error("DGB modules used before setDgbDeps() — see lib/dgb/deps.js");
return deps;
}
export const bitcoinjs = () => need().bitcoinjs;
export const payments = () => need().bitcoinjs.payments;
export const Psbt = () => need().bitcoinjs.Psbt;
export const ecc = () => need().ecc;
export const bip39 = () => need().bip39;
export const hmac = () => need().hmac;
export const sha512 = () => need().sha512;
// initEccLib must run before any Taproot derivation, and exactly once.
let eccInstalled = false;
export function ensureEcc() {
if (eccInstalled) return;
need().bitcoinjs.initEccLib(need().ecc);
eccInstalled = true;
}
// Built on first use rather than at module scope, so hd.js no longer forces
// the deps to exist merely by being imported.
let bip32Cached = null;
export function bip32() {
if (!bip32Cached) bip32Cached = need().bip32Factory(need().ecc);
return bip32Cached;
}
let ecpairCached = null;
export function ECPair() {
if (!ecpairCached) ecpairCached = need().ecpairFactory(need().ecc);
return ecpairCached;
}

View file

@ -1,11 +1,11 @@
import { Psbt } from 'bitcoinjs-lib';
import { Psbt } from '../deps.js';
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 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) {

View file

@ -22,7 +22,18 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
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); }
// A RANGE, not a flat "1.4". Fulcrum only attaches `token_data` to
// listunspent results once protocol >= 1.5 is negotiated, and with
// a flat 1.4 it silently omits it — which is why imported BCH
// wallets showed no CashTokens at all. A [min, max] pair lets a
// modern server pick 1.5.3 while an older one still settles on 1.4,
// so nothing that worked before stops working.
try {
const v = await this.call("server.version", ["theseus-bchwallet", ["1.4", "1.5.3"]]);
this.serverVersion = Array.isArray(v) ? v[0] : null;
this.protocolVersion = Array.isArray(v) ? v[1] : null;
resolve(this);
}
catch (e) { fail(e); this.close(); }
});
ws.on("error", fail);
@ -88,6 +99,19 @@ module.exports = function makeElectrum({ WebSocket, log = () => {} }) {
this.disconnect();
}
get url() { return this.conn && !this.conn.closed ? this.conn.url : null; }
// The protocol the live connection settled on. Callers use it to decide
// whether listunspent will carry `token_data` (>= 1.5) or whether they
// have to classify tokens the expensive way, by fetching each UTXO's
// parent transaction.
get protocolVersion() { return this.conn && !this.conn.closed ? (this.conn.protocolVersion || null) : null; }
// True when the server will report CashTokens on listunspent itself.
get hasTokenData() {
const v = String(this.protocolVersion || "");
const m = /^(\d+)\.(\d+)/.exec(v);
if (!m) return false;
const major = Number(m[1]), minor = Number(m[2]);
return major > 1 || (major === 1 && minor >= 5);
}
async _ensure() {
if (this.conn && !this.conn.closed) return this.conn;
if (this.connecting) return this.connecting;

View file

@ -7,7 +7,7 @@
// npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
HDKey, secp256k1, ed25519, sha256, sha512, hmac, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore,
}) {
@ -15,6 +15,17 @@ module.exports = function makeImportDerive({
// imported SC wallets end up with byte-identical addresses to what
// Sia Central Lite or walletd would show for the same seed.
const sia = blake2b ? require("./sia/sia.js")({ ed25519, blake2b }) : null;
// Taproot needs bitcoinjs-lib's schnorr backend installed via initEccLib,
// which is process-global and must happen once. This module used to rely on
// chain-btc.js (and, before it went lazy, lib/dgb/core/address.js) doing it
// at load time — a hidden dependency on an unrelated module's import order,
// which broke the moment either stopped being eagerly loaded. Own it here.
let eccInstalled = false;
function ensureEcc() {
if (eccInstalled) return;
try { if (bitcoinjs && bitcoinjs.initEccLib && ecc) bitcoinjs.initEccLib(ecc); } catch { /* p2tr will report it */ }
eccInstalled = true;
}
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const fromHex = (h) => {
const s = String(h || "").replace(/^0x/i, "");
@ -49,6 +60,7 @@ module.exports = function makeImportDerive({
if (purpose === 86) {
// Taproot — bitcoinjs.p2tr wants the 32-byte x-only pubkey.
const xonly = pk.slice(1, 33);
ensureEcc();
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
@ -69,6 +81,7 @@ module.exports = function makeImportDerive({
const net = BTC_NET[network];
if (purpose === 86) {
const xonly = pk.slice(1, 33);
ensureEcc();
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
@ -81,15 +94,29 @@ module.exports = function makeImportDerive({
if (!dgbCore) throw new Error("DGB adapter not available");
return dgbCore.digibyte;
}
function deriveDgbFromSeed(seedHex, path) {
// DigiByte's 2018-19 official mobile wallets (BreadWallet forks) built the
// master node with HMAC-SHA512 key "DigiByte seed" rather than BIP32's
// "Bitcoin seed", so the same words yield a different key tree and a
// standard scan finds nothing. Pass hmacKey to recover those.
// Ported from Digibyte.X/dgb-wallet @ 989a2696.
const HMAC_BITCOIN_SEED = "Bitcoin seed";
const HMAC_DIGIBYTE_SEED = "DigiByte seed";
function dgbRootFromSeed(seedBytes, net, hmacKey) {
const bip32 = bip32Factory(ecc);
if (!hmacKey || hmacKey === HMAC_BITCOIN_SEED) return bip32.fromSeed(Buffer.from(seedBytes), net);
if (!hmac || !sha512) throw new Error("this build cannot derive the legacy DigiByte master key");
const I = hmac(sha512, new TextEncoder().encode(hmacKey), Buffer.from(seedBytes));
return bip32.fromPrivateKey(Buffer.from(I.slice(0, 32)), Buffer.from(I.slice(32)), net);
}
function deriveDgbFromSeed(seedHex, path, hmacKey) {
const net = digibyteNetwork();
const node = bip32.fromSeed(Buffer.from(fromHex(seedHex)), net).derivePath(path);
const node = dgbRootFromSeed(fromHex(seedHex), net, hmacKey).derivePath(path);
const pk = Buffer.from(node.publicKey);
const m = /^m\/(\d+)'/.exec(String(path || ""));
const purpose = m ? Number(m[1]) : 84;
if (purpose === 86) {
const xonly = pk.slice(1, 33);
ensureEcc();
return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address;
}
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
@ -102,6 +129,7 @@ module.exports = function makeImportDerive({
const kp = ECPair.fromWIF(wif, net);
const purpose = hint || 84;
const pk = kp.publicKey;
ensureEcc();
if (purpose === 86) return bitcoinjs.payments.p2tr({ internalPubkey: pk.slice(1, 33), network: net }).address;
if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
@ -250,7 +278,10 @@ module.exports = function makeImportDerive({
return {
mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
dgb: { fromSeed: deriveDgbFromSeed, fromWif: deriveDgbFromWif },
dgb: {
fromSeed: deriveDgbFromSeed, fromWif: deriveDgbFromWif,
HMAC_BITCOIN_SEED, HMAC_DIGIBYTE_SEED,
},
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 },

View file

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,63 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
};
// Verbose transactions are public chain data; caching them on disk saves a
// round of fetches on every launch.
const txCache = storage.get("txCache", {}) || {};
// Cached transactions are slimmed on the way in, so a schema change to
// that slim shape has to invalidate them. v2 adds vout.tokenData; entries
// written by v1 carry no token information at all and an absent field is
// indistinguishable from "no token", so they are dropped once rather than
// trusted. Only the pre-1.5 fallback path reads this for classification,
// but a warm v1 cache there would silently report a token wallet as empty.
// v3 because v2 was unbounded. getTx() kept every transaction it ever
// fetched, with full vin/vout arrays, and a busy chipnet test wallet grew
// this to 7.7 MB. That matters enormously, because the host's addon store
// is ONE JSON file per add-on that is read, parsed, stringified and written
// whole and SYNCHRONOUSLY on the Electron main thread — the thread that
// drives the entire browser. A 7.7 MB cache turned every storage access
// into a multi-hundred-millisecond freeze of all of Theseus. Bumping the
// version also discards existing oversized caches on first load.
// v4: v2 was unbounded and v3 capped the PERSISTED set at 400, which is
// exactly the wrong number here — one consolidation in this wallet has 400
// inputs, so a single history row needed 401 entries and the cap thrashed.
// The persisted set is now just the displayed window; parents live in RAM.
const TX_CACHE_VERSION = 4;
// Guard on RAM, not on correctness. Parents are re-fetchable; this only
// stops a long session from growing without limit.
const TX_MEM_MAX = 20000;
const TX_FETCH_CONCURRENCY = 12;
let txCache = storage.get("txCache", {}) || {};
if (storage.get("txCacheVersion", 1) !== TX_CACHE_VERSION) {
txCache = {};
storage.set("txCache", txCache);
storage.set("txCacheVersion", TX_CACHE_VERSION);
}
// Only write when something actually changed. loadHistory() used to persist
// the cache on every refresh, so an idle wallet rewrote the whole file on
// every poll for nothing.
let txDirty = false;
// Process-lifetime, never persisted. Seeded from the persisted window so a
// restart does not refetch what is on screen.
const memTx = new Map();
for (const [id, t] of Object.entries(txCache)) memTx.set(id, t);
function rememberTx(id, slim) {
if (memTx.size >= TX_MEM_MAX) {
// Drop the oldest insertion; Map preserves insertion order.
const oldest = memTx.keys().next();
if (!oldest.done) memTx.delete(oldest.value);
}
memTx.set(id, slim);
}
// Fetch many transactions with bounded parallelism. Already-known ids cost
// nothing, so passing the same list twice is cheap.
async function getTxMany(ids) {
const want = [...new Set(ids)].filter((id) => {
const c = memTx.get(id);
return !(c && c.confirmations > 0);
});
for (let i = 0; i < want.length; i += TX_FETCH_CONCURRENCY) {
const batch = want.slice(i, i + TX_FETCH_CONCURRENCY);
await Promise.all(batch.map((id) => getTx(id).catch(() => null)));
}
}
let refreshTimer = null;
let subscribedHeaders = false;
@ -82,27 +138,57 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
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,
tokenData: x.token_data || null,
}));
}));
const utxos = lists.flat();
// Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript
// can classify it. getTx() already caches to disk, so a re-scan on a
// wallet with hundreds of UTXOs only fetches new ones. Failures are
// tolerated — an un-classifiable UTXO is treated as bare BCH, which
// is the conservative choice (worst case: user sees BCH value in
// balance but the coin selector still won't pick it if its token
// status matters — it just won't participate in a token send either).
// Classify each UTXO as bare BCH or CashToken.
//
// Two routes. When the server negotiated protocol >= 1.5 it reports
// `token_data` on listunspent itself, and — this is the part that
// matters — a UTXO WITHOUT token_data at that protocol is definitively
// not a token UTXO. So the whole set is classified from the one
// listunspent call, with zero further round-trips.
//
// Below 1.5 the server says nothing, so we fall back to fetching each
// UTXO's parent transaction and decoding the token prefix off its
// scriptPubKey. That is one request per UTXO: correct, cached to disk,
// and completely impractical on a faucet-fed chipnet address — a real
// one here holds 28,289 UTXOs, so a first scan meant ~28k requests and
// read as a hung wallet rather than as work in progress.
//
// Failures on the fallback path are tolerated: an unclassifiable UTXO is
// treated as bare BCH, which is the conservative choice — the coin
// selector may spend it as plain value, but it will never be pulled
// into a token send.
const tokenBalances = {};
await Promise.all(utxos.map(async (u) => {
try {
const t = await getTx(u.txid);
const out = t.vout[u.vout];
if (!out || !out.scriptHex) return;
const scriptBytes = tx.fromHex(out.scriptHex);
const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes);
u.scriptHex = out.scriptHex;
u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join("");
if (token) {
// Electrum's token shape -> the shape cashtokens.decodePrefixedScript
// returns, so everything downstream is identical whichever route found
// it. The two speak different dialects and must be reconciled here or an
// identical UTXO would describe itself differently depending on which
// server answered: the decoder yields a NUMERIC capability (0/1/2) with
// the labels immutable/mutable/minting, while Electrum sends a STRING
// and calls 0 "none". The decoder's vocabulary wins — it is the one
// already established here and in the CHIP.
const CAP_CODE = { none: 0, immutable: 0, mutable: 1, minting: 2 };
const CAP_LABEL = ["immutable", "mutable", "minting"];
const tokenFromElectrum = (td) => {
if (!td || !td.category) return null;
let amount = 0n;
try { amount = BigInt(td.amount || 0); } catch (_e) { amount = 0n; }
const nft = td.nft || null;
const code = nft ? (CAP_CODE[String(nft.capability || "none").toLowerCase()] ?? 0) : 0;
return {
categoryHex: String(td.category),
hasAmount: amount > 0n,
amount,
hasNft: !!nft,
commitmentHex: nft ? String(nft.commitment || "") : null,
capability: nft ? code : 0,
capabilityLabel: nft ? CAP_LABEL[code] : null,
};
};
const addToken = (u, token) => {
u.token = token;
const cat = token.categoryHex;
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] };
@ -116,11 +202,30 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
});
}
tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`);
};
if (client.hasTokenData) {
for (const u of utxos) {
const token = tokenFromElectrum(u.tokenData);
if (token) addToken(u, token);
}
} else {
await Promise.all(utxos.map(async (u) => {
try {
const t = await getTx(u.txid);
const out = t.vout[u.vout];
if (!out) return;
u.scriptHex = out.scriptHex;
// Prefer the server's own tokenData; fall back to decoding a
// prefix out of the script for a server that embeds it there.
const token = tokenFromElectrum(out.tokenData)
|| (out.scriptHex ? cashtokens.decodePrefixedScript(tx.fromHex(out.scriptHex)).token : null);
if (token) addToken(u, token);
} catch (e) {
log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e);
}
}));
}
state.utxos = utxos;
// Serialize BigInt fungible amounts as decimal strings for the snapshot
// (JSON.stringify chokes on BigInt otherwise).
@ -147,7 +252,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}
async function getTx(txid) {
const c = txCache[txid];
const c = memTx.get(txid) || txCache[txid];
if (c && c.confirmations > 0) return c;
const raw = await client.call("blockchain.transaction.get", [txid, true]);
const slim = {
@ -155,10 +260,21 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
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 })),
// tokenData was being dropped here, and that was the whole bug: the
// server reports CashTokens in this field, NOT inside
// scriptPubKey.hex, which Fulcrum returns with the token prefix
// already stripped. So the old classify pass fetched a transaction per
// UTXO, looked for a prefix that was never there, and concluded "no
// token" every single time. Keep it.
vout: (raw.vout || []).map((o) => ({
value: sats(o.value),
scriptHex: o.scriptPubKey && o.scriptPubKey.hex,
tokenData: o.tokenData || o.token_data || null,
})),
size: raw.size || 0,
};
txCache[txid] = slim;
rememberTx(txid, slim);
txDirty = true;
return slim;
}
@ -175,6 +291,20 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
return hb - ha;
}).slice(0, HISTORY_LIMIT);
const ours = new Set([...state.watched.values()].map((e) => e.scriptHex));
// Fetch in two parallel waves instead of one long sequential chain. This
// loop used to await getTx() per displayed transaction and then again per
// INPUT of each, one at a time. A consolidation here has 400 inputs, so a
// single history row cost 400 round trips in series — tens of seconds,
// during which every response still had to be parsed on the main thread.
// Median input count is 2, so the common case gets faster too.
await getTxMany(ordered.map((h) => h.txid));
const parentIds = [];
for (const h of ordered) {
const t = await getTx(h.txid);
for (const i of t.vin) if (i.txid) parentIds.push(i.txid);
}
await getTxMany(parentIds);
const out = [];
for (const h of ordered) {
const t = await getTx(h.txid);
@ -182,7 +312,7 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
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 p = await getTx(i.txid); // warm after the wave above
const po = p.vout[i.vout];
if (!po) continue;
inputsTotal += po.value;
@ -200,7 +330,22 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
});
}
state.history = out;
storage.set("txCache", txCache);
// Persist ONLY the displayed window. Parent transactions are needed to
// compute a delta and nothing else, and persisting every one ever seen is
// what grew this store to 7.4 MB across 6,981 entries — which the host
// then re-parsed on every storage read, on the main thread. They stay in
// the in-memory map instead, so a session never refetches them and the
// file stays a few tens of KB.
const keep = {};
for (const h of ordered) {
const t = memTx.get(h.txid);
if (t) keep[h.txid] = t;
}
const before = JSON.stringify(txCache);
const after = JSON.stringify(keep);
txCache = keep;
if (after !== before) storage.set("txCache", keep);
txDirty = false;
}
function scriptToAddress(scriptHex) {
@ -211,8 +356,22 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
return null;
}
// A manual refresh used to return here the moment a background poll was
// in flight, and refreshChain reported ok:true for it — so the Refresh
// button no-opped and claimed success, which is exactly when a user is
// most likely to press it. A forced refresh now waits for the in-flight
// pass and then does real work; a background poll still yields.
let inflight = null;
async function refresh(full = false) {
if (state.scanning) return;
if (state.scanning) {
if (!full) return;
try { await inflight; } catch { /* its own error is already on state */ }
if (state.scanning) return; // another forced pass won the race
}
inflight = doRefresh(full);
return inflight;
}
async function doRefresh(full) {
state.scanning = true; state.error = null; onChange();
try {
if (full || !state.watched.size) await scan();

View file

@ -37,8 +37,23 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
hash256, encodeTransaction,
} = libauth;
const tx = ensureTransaction(request.transaction, libauth);
const sourceOutputs = (request.sourceOutputs || []).map((o, i) => {
// The WC message nests the whole WcSignTransactionRequest under
// `.transaction`, so the real shape is:
// request.transaction.transaction — the tx (object or hex)
// request.transaction.sourceOutputs — the spent outputs
// request.inputPaths / request.sequence — on the outer message
// Reading request.transaction as the tx (and request.sourceOutputs as
// the outputs) meant `tx.inputs` was undefined and signing threw on the
// first real request. index.js already read the nested
// request.transaction.userPrompt for the approval dialog, so only this
// module had it wrong. The flat shape is still accepted so a caller
// that hands us an already-unwrapped payload keeps working.
const inner = (request.transaction && (request.transaction.transaction !== undefined
|| request.transaction.sourceOutputs !== undefined))
? request.transaction
: request;
const tx = ensureTransaction(inner.transaction, libauth);
const sourceOutputs = (inner.sourceOutputs || []).map((o, i) => {
if (o.contract) throw new Error(`wc-sign: input ${i} spends a contract — unsupported`);
return {
lockingBytecode: o.lockingBytecode instanceof Uint8Array ? o.lockingBytecode : fromHex(o.lockingBytecode),
@ -63,12 +78,22 @@ async function signTx({ request, account, branches, libauth, secp256k1 }) {
if (!branch) throw new Error(`wc-sign: unknown path "${hint.pathName}"`);
const node = branch.deriveChild(hint.addressIndex);
const preimage = generateSigningSerializationBCH({
// generateSigningSerializationBCH takes TWO positional arguments:
// (compilationContext, { coveredBytecode, signingSerializationType })
// Passing one merged object left coveredBytecode undefined, which threw
// "Cannot destructure property 'coveredBytecode' of 'undefined'".
// For P2PKH the covered bytecode is the spent output's locking script.
const preimage = generateSigningSerializationBCH(
{
inputIndex: i,
signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]),
sourceOutputs,
transaction: { ...tx, inputs: signedInputs },
});
},
{
coveredBytecode: sourceOutputs[i].lockingBytecode,
signingSerializationType: new Uint8Array([REQUIRED_SIGHASH]),
},
);
const digest = hash256(preimage);
const sig = secp256k1.sign(digest, node.privateKey, { prehash: false, lowS: true, format: "der" });
// signature || sighashType byte

View file

@ -171,12 +171,29 @@ module.exports = function makeWc({ HDKey, secp256k1, sha256, hkdf, WalletConnect
function snapshot() {
const out = {};
for (const [walletId, mgr] of managers) {
const list = [...(mgr.connections?.values?.() || [])].map((c) => ({
// getConnections() is the documented accessor and returns a plain
// {id: RelayConnectionState} record. We used to walk mgr.connections
// (a private Map) directly, which works but is one library refactor
// away from silently returning nothing.
const states = typeof mgr.getConnections === "function"
? Object.values(mgr.getConnections() || {})
: [...(mgr.connections?.values?.() || [])];
const list = states.map((c) => ({
id: c.id,
uri: c.uri,
// `label` is the library's own "dapp name once known, otherwise
// Connecting…", so it's the right thing to show while a pairing
// is still settling.
label: c.label || null,
dappName: c.dappName || null,
dappIcon: c.dappIcon || null,
status: c.status?.kind || String(c.status || "unknown"),
// RelayStatus is an OBJECT: { status: "connected" | "reconnecting"
// | "disconnected" | "session_deleted" }. Reading `.kind` (which
// does not exist) fell through to String(object) and put the
// literal "[object Object]" in the panel's status field.
status: typeof c.status === "string"
? c.status
: (c.status?.status || "unknown"),
connectedAt: c.connectedAt || null,
}));
out[walletId] = list;

View file

@ -32,11 +32,24 @@
.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; }
/* Balance hero. Every modern wallet leads with one large numeral and pushes
the fractional part back to ~40% opacity, so the eye reads magnitude
before precision; the ticker and the fiat conversion are annotations on
that number rather than peers of it. Baseline-aligned flex keeps them on
the numeral's baseline however long the amount grows. */
.bal { margin-top: 10px; font-variant-numeric: tabular-nums; }
.bal .big { font-size: 32px; font-weight: 680; letter-spacing: -.6px; line-height: 1.1;
display: flex; align-items: baseline; gap: 5px; flex-wrap: wrap; }
.bal .big .dec { color: var(--dim); font-weight: 620; }
/* Marks a shortened tail, and only appears when one was actually cut. */
.bal .big .more { opacity: .5; margin-left: 1px; }
/* Pointer only when there is something to toggle — a balance that already
fits shows no affordance it cannot honour. */
.bal .big #balMain.balx { cursor: pointer; }
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 0; }
/* Scoped to the hero — .fiat is also the Send form's conversion line. */
.bal .big .fiat { font-size: 14px; margin-left: 6px; }
/* .sub is gone — its contents moved up into .netrow, see below. */
/* Full-panel sheet — fills most of the sidebar but stops above the
footer so the aegis.x brand + version chip stay visible. Users
opening + should still know which build they're on and be able
@ -79,11 +92,61 @@
#drop .brnet.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14); border-color: rgb(from var(--acid, #d6ff3d) r g b / .55);
color: var(--acid, #d6ff3d); font-weight: 600; }
#drop .brnet .hint { color: currentColor; opacity: .65; font-size: 11px; }
.histfilter { display: flex; align-items: center; gap: 14px; padding: 0 2px 10px;
border-bottom: 1px solid var(--line); margin-bottom: 8px; font-size: 12px; }
.histfilter[hidden] { display: none; }
/* Assets card rows (0.9.2). The native coin leads the list; a symbol
claimed by several contracts gets a LOOK-ALIKE tag so a borrowed
ticker can't pass for the real token. */
.tx.asset { padding: 7px 8px; border-radius: 6px; }
.tx.asset:hover { background: rgba(255,255,255,.04); }
.tx.asset.native { background: rgb(from var(--acid, #d6ff3d) r g b / .06); }
.ttag.tdupe { background: rgba(224,90,90,.18); color: #f6768a; cursor: help; }
.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; }
/* 0.8.4: hNet became a proper chip on its own row (.netrow) so the
network the wallet is on is easy to spot AND easy to switch. */
.netrow { display: flex; margin-top: 6px; padding: 0 2px; }
network the wallet is on is easy to spot AND easy to switch.
0.10.0: that row carried one small chip and ~300px of dead space, while
a second row under the balance carried the connection state and the
per-wallet actions. Two thin rows sandwiching the balance pushed the
number people open the panel for into the middle of the header. They are
one row now: [network] [state] ........ [Explorer] [Faucet], above the
balance. The whole row hides together when there is no wallet context,
which is the only case the old .netrow hid in. */
/* 0.25.0: the network chip left this row to become the selector below the
balance, so what remains is purely connection state plus the per-wallet
verbs. */
.netrow { display: flex; align-items: center; gap: 7px; margin-top: 6px;
padding: 0 2px; min-width: 0; }
/* Network selector — indicator and switch in one control, directly under
the balance and present whenever a wallet is. The active chip IS the
"you are here" marker the status row used to carry. */
/* The "Ask for PIN" trigger list. Stacked under its own label rather than
one switch per row: four rows of switches read as four unrelated
settings, when they are one question with four answers. */
.gsec .gline .pinon { display: flex; flex-direction: column; gap: 5px; margin-top: 7px; }
.gsec .gline .pinon label { display: flex; align-items: center; gap: 7px; font-size: 12px;
color: var(--ink); cursor: pointer; font-weight: 400; }
.gsec .gline .pinon input { margin: 0; }
.netsel { display: flex; gap: 6px; margin-top: 12px; padding: 0 2px; flex-wrap: wrap; }
.netsel[hidden] { display: none; }
.netselchip { background: transparent; border: 1px solid var(--line); color: var(--mut);
border-radius: 999px; padding: 4px 11px; font: inherit; font-size: 12px;
line-height: 1.15; cursor: pointer; display: inline-flex; align-items: center;
gap: 6px; white-space: nowrap; }
.netselchip:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
.netselchip.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .50);
color: var(--ink); font-weight: 600; cursor: default; }
.netselchip .cnt { color: var(--dim); font-size: 10.5px; font-weight: 400; }
.netselchip.on .cnt { color: var(--mut); }
/* Only the hostname may shrink. Without this the flexbox took width off
the network chip first, clipping "Chipnet testnet" to "Chipnet" — the
one label on the row that must never be ambiguous, since it is what
tells you whether you are about to move real money. */
.netrow .netchip, .netrow .net, .netrow .hdracts { flex: none; }
.netrow .netlbl { flex: 1 1 auto; color: var(--dim); font-size: 11.5px; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.netchip { background: rgba(255,255,255,.05); border: 1px solid var(--line);
color: var(--mut); border-radius: 999px; padding: 3px 10px 3px 8px;
font: inherit; font-size: 11.5px; cursor: pointer; display: inline-flex;
@ -92,6 +155,9 @@
.netchip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .7; }
/* Currency-picker search + all-coins catalogue (0.8.4). */
#drop .pickersearch { padding: 8px; border-bottom: 1px solid var(--line); }
/* Search + close share the top row now that the title row is gone. */
#drop .pickersearch-top { display: flex; align-items: center; gap: 6px; }
#drop .pickersearch-top input { flex: 1; min-width: 0; }
#drop .pickersearch input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,.04);
border: 1px solid var(--line); color: var(--ink); border-radius: 7px;
padding: 7px 10px; font: inherit; font-size: 12.5px; outline: none; }
@ -99,11 +165,25 @@
#drop .catgroup { padding: 4px 6px 2px 10px; color: var(--dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; }
#drop .row.unowned { opacity: .8; }
#drop .row.unowned .v { color: var(--acid, #d6ff3d); font-weight: 600; font-size: 11px; }
/* Per-wallet actions promoted into the header's status row (0.9.2). */
.hdracts { margin-left: auto; display: inline-flex; gap: 4px; }
.hdract { background: transparent; border: 1px solid var(--line); color: var(--dim);
border-radius: 999px; padding: 1px 9px; font: inherit; font-size: 10.5px;
cursor: pointer; line-height: 1.5; }
.hdract:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
.hdract[hidden] { display: none; }
.fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; }
.portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
.portfolio b { color: var(--ink); font-weight: 600; }
.portfolio[hidden] { display: none; }
.picker-actions { display: flex; align-items: center; gap: 6px; }
/* "this is the one I pay from" / "this is the one dapps get". Deliberately
quiet — it labels a row, it is not a control, and a wallet list where
every row shouts is a list nobody reads. */
.rolebadge { font-size: 10px; line-height: 1; text-transform: uppercase; letter-spacing: .04em;
border: 1px solid var(--line); color: var(--mut); border-radius: 4px;
padding: 2px 4px; white-space: nowrap; }
.rolebadge.pay { color: var(--acid); border-color: color-mix(in srgb, var(--acid) 40%, var(--line)); }
.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; }
@ -141,12 +221,71 @@
.setsecnav button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16);
color: var(--acid, #d6ff3d); font-weight: 600; }
.setsecnav button:hover:not(.on) { color: var(--ink); }
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); }
/* Segmented pill bar. The underline-tab treatment read as browser chrome,
and a 2px underline under a short label is nearly invisible once the
sidebar is narrow. A filled pill for the active section is what the
wallet apps use and it holds up at any width. The hover tint derives
from --ink so it also shows in the light theme, where a white overlay
would vanish. */
nav { display: flex; gap: 4px; padding: 6px 8px; border-bottom: 1px solid var(--line); background: var(--panel); }
nav button { flex: 1; padding: 7px 0; border: 0; border-radius: 999px; background: transparent;
color: var(--mut); cursor: pointer; font: inherit; font-size: 12.5px;
transition: background .12s, color .12s; }
nav button.on { background: var(--acid); color: #0b0f08; font-weight: 650; }
nav button:hover:not(.on) { background: rgb(from var(--ink) r g b / .08); color: var(--ink); }
main { flex: 1; overflow: auto; padding: 14px; }
/* ---- full-screen surface (?surface=web) -------------------------------
The same panel.html, opened as a Theseus tab through the "open-tab"
capability. Nothing is duplicated: an add-on's own tab gets a
window.silentmode with the SAME invoke/on surface as the sidebar, and
main dispatches it as from:"panel", so panel.js runs here unchanged and
this is purely a re-layout.
The sidebar becomes a left rail — identity, network, balance, section
nav and the wallet list, in the order they already appear — and the
tab body gets the rest of the window. Everything below only applies
when the surface flag is set, so the 380px panel is untouched. */
:root[data-surface="web"] body {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-template-areas: "head main" "nav main" "strip main" "foot main";
height: 100vh; overflow: hidden;
}
:root[data-surface="web"] header { grid-area: head; }
:root[data-surface="web"] nav { grid-area: nav; }
:root[data-surface="web"] .wstrip{ grid-area: strip; max-height: none; }
:root[data-surface="web"] .brandfoot { grid-area: foot; position: static; }
:root[data-surface="web"] main {
grid-area: main; padding: 22px 26px 40px;
border-left: 1px solid var(--line); background: var(--bg);
}
/* Forms and lists stop stretching into unreadable line lengths on a wide
monitor; the column keeps the measure the sidebar already tuned for. */
:root[data-surface="web"] main > #tabs,
:root[data-surface="web"] main > .gate { max-width: 720px; }
/* The picker sheet is fixed-position and sized for a 380px panel. Pin it
to the rail instead of letting it cover the whole window. */
:root[data-surface="web"] #drop { right: auto; width: 340px; bottom: 34px; }
/* The lock screen still owns the whole window — a locked vault should not
look like a half-usable app. */
:root[data-surface="web"] #lockScreen { inset: 0; }
@media (max-width: 900px) {
/* Narrow window: fall back to the stacked sidebar layout. */
:root[data-surface="web"] body {
display: flex; flex-direction: column; height: auto; overflow: visible;
}
:root[data-surface="web"] main { border-left: 0; padding: 14px; }
:root[data-surface="web"] #drop { right: 0; width: auto; }
}
/* The QR is drag-sized against the 380px sidebar and its size is
remembered. Given a 720px column it stretches to fill and dominates the
page, so cap it here rather than touching the stored size — the user's
sidebar preference stays exactly as they left it. */
:root[data-surface="web"] .qrwrap { max-width: 320px; }
/* "Open full screen" chip — hidden once we ARE the full-screen surface. */
:root[data-surface="web"] #hFull { display: none; }
/* Persistent aegis.x brand strip. Sticks to the panel's bottom edge so the
wallet always advertises its own front-door site — helps users bookmark
it, and doubles as a version marker for support triage. */
@ -198,7 +337,35 @@
.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; }
.qrwrap { display: grid; place-items: center; padding: 12px; background: #fff; border-radius: 10px; margin-top: 12px; }
.qrwrap[hidden] { display: none; }
/* The canvas scales to whatever height the user dragged the panel to,
and never wider than the sidebar. aspect-ratio keeps it square. */
.qrwrap canvas { width: auto; height: auto; max-width: 100%;
aspect-ratio: 1 / 1; image-rendering: pixelated; }
/* Drag handle under the QR. A thin strip rather than a corner grip, so
it stays reachable at any sidebar width. */
.qrgrip { height: 14px; margin: 2px 0 -4px; display: grid; place-items: center;
cursor: ns-resize; touch-action: none; }
.qrgrip span { display: block; width: 34px; height: 3px; border-radius: 999px;
background: var(--line); transition: background .12s; }
.qrgrip:hover span, .qrgrip.dragging span { background: var(--acid, #d6ff3d); }
/* Address + inline copy. The value clamps to two lines; anything longer
is cut rather than growing the card. */
.addrrow { display: grid; grid-template-columns: minmax(0,1fr) 30px; gap: 6px;
align-items: start; }
.addrval { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; word-break: break-all; line-height: 1.35; }
.addrcopy { background: transparent; border: 1px solid var(--line); color: var(--dim);
cursor: pointer; width: 30px; height: 30px; border-radius: 7px;
display: inline-flex; align-items: center; justify-content: center;
line-height: 0; transition: color .12s, border-color .12s, background .12s; }
.addrcopy:hover { color: var(--acid, #d6ff3d); border-color: rgb(from var(--acid, #d6ff3d) r g b / .5); }
.addrcopy.copied { color: var(--acid, #d6ff3d); border-color: rgb(from var(--acid, #d6ff3d) r g b / .6); }
.addrcopy svg { width: 15px; height: 15px; display: block; }
/* Hide the action row entirely when its only child is hidden, so it
doesn't leave a gap under the address. */
#addrActions:not(:has(> :not([hidden]))) { display: none; }
canvas { image-rendering: pixelated; }
input[type=text], input[type=number], input[type=password], 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; }
@ -394,31 +561,60 @@
"a" and "anthem…" occupy the same slot, amount is fixed to 90px so
"0" and "0.000123" right-align identically, actions are fixed to
46px. Net result: columns look like a table, not a flex mess. */
/* 0.9.8: five columns for the five cells the row actually renders —
icon · label · copy · amount · menu. The template used to describe
the pre-0.9.2 layout (six columns, including an address cell that no
longer exists), so every cell sat one column left of where it
belonged, which is what made copy and amount collide.
0.9.9: the columns are declared ONCE on the list and every row
inherits them via subgrid. Previously each row was its own grid, so
the `auto` amount column resolved per row — a row holding
0.3066756 got a narrower column than one holding 0.43789841, and the
copy button landed at a different x on every line. Column widths
must be shared to line up, and subgrid is what shares them. */
/* Column gap lives on the parent: a subgrid takes its gutters in the
subgridded axis from the grid it inherits, so setting it on the row
would be ignored. */
.wstrip .walist { display: grid;
grid-template-columns: 16px minmax(0,1fr) 24px auto 26px;
column-gap: 8px; row-gap: 2px; }
.wstrip .warow { display: grid;
grid-template-columns: 16px minmax(0,1fr) 22px 68px 90px 46px;
gap: 6px; align-items: center; padding: 4px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 28px; }
grid-template-columns: subgrid; grid-column: 1 / -1;
align-items: center; padding: 5px 6px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; }
/* Fallback for a host without subgrid: pin the amount column to a fixed
width so the copy column still lines up. Wide enough for eight
decimals of BCH plus its ticker. */
@supports not (grid-template-columns: subgrid) {
.wstrip .walist { display: block; }
.wstrip .warow { grid-template-columns: 16px minmax(0,1fr) 24px 104px 26px;
grid-column: auto; gap: 8px; }
}
.wstrip .warow:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .warow .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Copy chip anchored right after the address. Fixed 22px column so the
copy button sits at the same x on every row. */
/* Copy chip: its own 24px column between label and amount, so it can
never crowd the number. SVG rather than the 📋 emoji, which rendered
as a tofu box here and read as a glyph stuck to the balance. */
.wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font-size: 11px; line-height: 1;
transition: color .12s; justify-self: start; }
padding: 0; border-radius: 5px; line-height: 0;
width: 24px; height: 22px; display: inline-flex;
align-items: center; justify-content: center;
transition: color .12s, background .12s; justify-self: center; }
.wstrip .warow .wacopy svg { width: 13px; height: 13px; display: block; }
.wstrip .warow .wacopy:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
.wstrip .warow .wacopy.copied { color: var(--acid, #d6ff3d); }
/* Label pill: fixed 68px column. Even an empty label renders an
invisible placeholder so the amount column stays put. Long labels
truncate with ellipsis inside the pill (max-width: 100% of column). */
.wstrip .warow .walabel { font-size: 11px; color: var(--mut);
padding: 1px 6px; border-radius: 999px;
background: rgba(255,255,255,.06);
/* Label pill, left-aligned in the flexible column so a name reads as
the row's identity rather than floating mid-row. Truncates with an
ellipsis instead of pushing the number around. */
.wstrip .warow .walabel { font-size: 11.5px; color: var(--ink);
padding: 2px 8px; border-radius: 999px;
background: rgba(255,255,255,.07);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 100%; box-sizing: border-box;
justify-self: center; }
justify-self: start; }
.wstrip .warow .walabel:empty { visibility: hidden; }
/* Balance shares a single line with everything else — no vertical
amount/fiat stack. Ticker follows the number in a dim tone so the
@ -430,11 +626,9 @@
justify-self: end; overflow: hidden; }
.wstrip .warow .waamt .watkr { color: var(--dim); font-size: 11px; font-weight: 500; }
.wstrip .warow .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
padding: 2px 4px; border-radius: 4px; font-size: 13px; line-height: 1;
justify-self: end; }
.wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Destructive strip action (remove wallet). Stays quiet until hovered so
a row of icons doesn't read as a row of warnings. */
.wstrip .wact.wactdel:hover { color: var(--danger, #f6768a); background: rgb(from var(--danger, #f6768a) r g b / .12); }
/* Per-address asset list (0.8.8). The count chip expands the row into a
nested list of what that ONE address holds beyond the native coin. */
.wstrip .waassets { background: rgba(255,255,255,.06); border: 0; color: var(--dim); cursor: pointer;
@ -549,27 +743,44 @@
Create new / Import / Connect / About — replacing the old
three-chip cluster (+ ⋯ ✎). Method chosen there routes into
the coin picker inside #drop or an appropriate modal. -->
<button type="button" id="hFull" class="chip" title="Open the wallet full screen">⤢</button>
<button type="button" id="hAdd" class="chip" title="Add or import a wallet">+</button>
</div>
</div>
<!-- 0.8.4: network picker is its own row below the coin label so it
reads as "the network I'm on right now" instead of a tiny suffix
next to the wallet name. Click cycles / opens the network menu. -->
<div class="netrow" id="hNetRow" hidden>
<button type="button" class="netchip" id="hNet" title="Switch network"></button>
</div>
<div id="drop" hidden></div>
<!-- 0.25.0 header order: balance, then the network selector, then status.
The number you came to see is first; the control you act with is next;
the connection detail that only matters when something is wrong is
last. -->
<div class="bal">
<div class="big">
<span id="balMain">—</span><small id="balTicker">—</small>
<span id="balFiat" class="fiat" hidden></span>
</div>
<div class="sub">
<span class="netlbl" id="netlbl">connecting…</span>
<span class="net"><span class="dot" id="dot"></span></span>
</div>
<div id="portfolio" class="portfolio" hidden></div>
</div>
<!-- Network indicator and network switch are the same control. Until now
the indicator sat in the status row while the actual selector was a
chip row buried in the picker's coin drilldown — two places for one
idea, and the half that could change anything was two clicks deep.
This row is always present while a wallet is selected. -->
<div class="netsel" id="hNetSel" hidden></div>
<div class="netrow" id="hNetRow" hidden>
<span class="net"><span class="dot" id="dot"></span></span>
<!-- Not just a hostname: this line is also where "connecting…",
"· syncing", "· <n> unconfirmed" and the walletd setup prompt are
written, so it cannot move to Settings without hiding an
unconfirmed balance and a setup instruction. -->
<span class="netlbl" id="netlbl">connecting…</span>
<!-- 0.9.2 moved Explorer/Faucet out of the Receive card because they act
on the selected wallet, not on the act of receiving. They stay a
per-wallet action for the same reason they are not Settings: they
are verbs on the current wallet, not configuration. -->
<span class="hdracts">
<button class="hdract" id="viewAddr" type="button" title="Open this address in a block explorer">Explorer</button>
<button class="hdract" id="openFaucet" type="button" title="Open this testnet's faucet" hidden>Faucet</button>
</span>
</div>
<div id="drop" hidden></div>
</header>
<nav>
<button data-tab="receive" class="on">Receive</button>
@ -590,30 +801,84 @@
the normal Receive view (QR + address) and the Consolidate
view (inline picker for sweeping other-wallet balances into
this one). Hidden when there is nothing to consolidate. -->
<div id="rcvModeToggle" class="modetoggle" hidden>
<button data-rcv-mode="receive" class="on" type="button">Receive</button>
<button data-rcv-mode="consolidate" type="button">Consolidate <span id="rcvConsolidateCount" class="hint"></span></button>
</div>
<!-- 0.18.0: the Receive tab's Receive/Consolidate toggle is gone. Two
stacked toggle rows (that one, then Address & QR / Assets) was a
lot of chrome for a 380px panel, and the two were not the same kind
of thing: Address and Assets are views of the wallet, whereas
consolidating is an ACTION on its UTXO set. It now sits with the
other address actions, next to "Next unused address". The Send tab
keeps its own toggle — there, consolidate really is an alternative
way to send. -->
<div id="rcvNormal">
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
<!-- 0.16.0: Address-and-QR and Assets are PEERS, switched by a
persistent chip row, instead of one stacked column. Stacking meant
the QR sat between the address and the token list, so assets were
permanently below the fold in a 380px panel; and it made every coin
look different, since a coin with no tokens simply lost a section.
Chips give one shape for every coin and a visible answer either
way. (0.9.2 had already reordered the stack for the same reason —
this replaces that workaround.) -->
<!-- 0.19.0: Assets split into Assets and Certificates. A fungible
balance and a non-fungible one answer different questions ("how
much do I have" vs "which ones do I hold") and want different rows
— an amount against a symbol, versus a commitment and a
capability. One list mixing them showed 46 categories where 32
were only ever going to say "1 NFT". Split by what the category
actually holds, so one carrying both appears in both.
"Certificates", not "NFTs", in the UI: these are membership,
licence and record tokens, and NFT carries collectible-market
baggage that misdescribes them. The ids and data keys below stay
`nft` because that is the CashTokens protocol field — protocol
name inside, product name on screen. -->
<div id="rcvViewToggle" class="modetoggle">
<button data-rcv-view="address" class="on" type="button">Address</button>
<button data-rcv-view="assets" type="button">Assets <span id="rcvAssetsCount" class="hint"></span></button>
<button data-rcv-view="nfts" type="button">Certificates <span id="rcvNftsCount" class="hint"></span></button>
</div>
<div id="rcvAddressPane">
<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>
<!-- 0.9.8: address and its copy button share one row, so the
button sits at the end of the value it copies instead of on a
separate action line. The address clamps to two lines and
truncates beyond that, rather than growing the card in a
narrow sidebar. -->
<div class="addrrow">
<div class="mono addrval" id="addr">—</div>
<button class="addrcopy" id="copyAddr" title="Copy this address"></button>
</div>
<div class="actions" id="addrActions">
<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>
<button class="btn sm" id="rcvConsolidateBtn" hidden title="Sweep this coin's other wallets into this address">⇢ Consolidate <span id="rcvConsolidateCount" class="hint"></span></button>
</div>
<!-- Drag the bottom edge to resize the QR. Height persists. -->
<div class="qrwrap" id="qrWrap"><canvas id="qr" width="200" height="200"></canvas></div>
<div class="qrgrip" id="qrGrip" title="Drag to resize the QR code"><span></span></div>
</div>
<div class="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div>
</div><!-- /rcvAddressPane -->
<div id="rcvAssetsPane" hidden>
<div class="card" id="tokensCard">
<div class="lbl">Assets <span class="hint" id="tokensCount"></span></div>
<div id="tokensList" class="kv"></div>
<div class="hint" id="tokensHint">Tokens held by this wallet.</div>
</div>
</div>
<div id="rcvNftsPane" hidden>
<div class="card" id="nftsCard">
<div class="lbl">Certificates <span class="hint" id="nftsCount"></span></div>
<div id="nftsList" class="kv"></div>
<div class="hint" id="nftsHint">Non-fungible CashTokens held by this wallet — one row per certificate.</div>
</div>
</div>
</div><!-- /rcvNormal -->
<!-- Consolidate view (inline, replaces Receive body when active). -->
<div id="rcvConsolidate" hidden>
<!-- The toggle used to be the only way out of this view, so removing
it means this needs its own way back. -->
<div class="actions" style="margin-bottom:8px">
<button class="btn sm" id="rcvConsolidateBack" type="button">← Back to address</button>
</div>
<div id="rcvConsolidateInline"></div>
</div>
</section>
@ -633,6 +898,16 @@
<span class="hint" id="consolidateChipCount"></span>
</button>
</div>
<!-- Which address the money leaves from. Only rendered when this coin
and network actually has more than one wallet, so a single-wallet
setup keeps a form with nothing to decide. Switching it switches
the panel's selected wallet, because that is what planSend/send
spend from — one source of truth, no second notion of "current". -->
<div class="field" id="sendFromField" hidden>
<div class="lbl">From</div>
<select id="sendFrom"></select>
<div class="hint" id="sendFromHint"></div>
</div>
<div class="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div>
<select id="sendAsset"></select>
@ -679,6 +954,14 @@
</div>
</section>
<section id="tab-history" hidden>
<!-- 0.9.2: token movement used to be invisible here — a wallet that
only ever moved USDT looked like it had no history. Both feeds
are on by default; the filters exist to narrow, not to opt in. -->
<div class="histfilter" id="histFilter" hidden>
<label class="switch"><input type="checkbox" id="histNative" checked><span id="histNativeLbl">Coin</span></label>
<label class="switch"><input type="checkbox" id="histTokens" checked><span>Tokens</span></label>
<span class="hint" id="histCount" style="margin-left:auto"></span>
</div>
<div id="txlist"></div>
</section>
<section id="tab-settings" hidden>
@ -714,13 +997,35 @@
<button class="btn sm danger" id="gsPinRemove" hidden>Remove</button>
</div>
</div>
<div class="gline" id="gsRequirePinLine" hidden>
<!-- Independent triggers, not a mode. Wanting a PIN at startup and
again per transaction is a normal combination, and the old
single "require PIN for sending" flag produced the worst of both:
the wallet reopened unlocked after a restart and then demanded a
PIN the moment you touched something. With none of these ticked
the wallet never asks again once it is open. -->
<div class="gline" id="gsPinOnLine" hidden>
<div class="glabel">
Require PIN for sending
<span class="ghint">Prompts for your PIN on every panel-initiated Send. Dapp-driven approvals still use the standard approval overlay.</span>
Ask for PIN
<span class="ghint">When Aegis should make you re-enter your PIN. Untick everything and it only asks when the vault itself is locked.</span>
<div class="pinon">
<label><input type="checkbox" id="gsPinOnRestart"> On each browser restart</label>
<label><input type="checkbox" id="gsPinOnLaunch"> On each wallet launch (hide / show)</label>
<label><input type="checkbox" id="gsPinOnInterval"> Every 6 hours</label>
<label><input type="checkbox" id="gsPinOnTransaction"> Each transaction</label>
</div>
</div>
</div>
<!-- Turning this OFF does not make a secret free to read: it moves
the prompt from the PIN to the master password, which is the
stronger credential. There is no "ask me nothing" setting for
showing a key, by design. -->
<div class="gline" id="gsRequirePinRevealLine" hidden>
<div class="glabel">
Use PIN to show secret keys
<span class="ghint">On, Aegis takes your PIN before showing a wallet's secret key, and asks for the master password after three wrong tries. Off, it asks for the master password straight away.</span>
</div>
<div class="gactions">
<label class="switch"><input type="checkbox" id="gsRequirePin"><span></span></label>
<label class="switch"><input type="checkbox" id="gsRequirePinReveal"><span></span></label>
</div>
</div>
</div>
@ -1082,7 +1387,14 @@
<span class="brandver" id="brandVer"></span>
</span>
</footer>
<script src="qr.js"></script>
<script src="panel.js"></script>
<!-- jsqr.js is deliberately NOT loaded here: 257 KB of vendored decoder,
parsed on every panel open (so on every hide/show), for a feature most
sessions never touch. panel.js fetches it the first time someone imports
a QR image. `defer` lets the browser paint the already-dark document
before executing 300 KB of panel.js, which shortens the blank window
while the view loads. Order is preserved, so qr.js is still defined
before panel.js runs. -->
<script src="qr.js" defer></script>
<script src="panel.js" defer></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,18 @@ const call = (msg, payload) =>
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) {
// -------- BCH bridge (window.bitcoincash), everywhere -----------------------
//
// This used to be gated on a `.x` hostname, inherited from the
// pre-multi-wallet build where every dapp Aegis knew about was a Silent Mode
// one. It was never a security boundary: the TRX / ETH / SOL bridges have
// always injected on every page, and every call here still crosses into
// main, raises an approval overlay and honours per-origin permissions.
// Nothing in this file can read an address or sign anything on its own.
//
// Keeping it meant Cauldron, bch.guru and our own non-.x dapps (potidaea.asm)
// saw no BCH provider at all, on a wallet whose whole point is BCH.
// WizardConnect below was never gated this way, so the two halves disagreed.
theseus.contextBridge.exposeInMainWorld("bitcoincash", {
isTheseus: true,
version: "0.2.0",
@ -30,7 +39,6 @@ if (isBchOrigin) {
signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}),
signMessage: (message) => call("signMessage", { message: String(message ?? "") }),
});
}
// -------- Tron bridge (window.tronWeb, window.tronLink), everywhere ---------
//
@ -543,11 +551,37 @@ const mainWorldSource = `(function () {
// 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.
//
// Sites that enforce Trusted Types (CSP `require-trusted-types-for 'script'`)
// refuse a text script AND report the attempt to their CSP endpoint. Google's
// sign-in pages do exactly that, and a "something injected a script into our
// login page" report is a fine reason for them to call the browser insecure.
// No dapp lives on those origins, so the bridge stays out: a static list for
// the big enforcing sites (no report at all), plus a per-origin memory for
// any other site that rejected us once (one report, never again).
const NO_BRIDGE_HOSTS = /(^|\.)(google\.[a-z.]+|googleapis\.com|gstatic\.com|youtube\.com|googleusercontent\.com|microsoftonline\.com|microsoft\.com|live\.com|office\.com|github\.com|apple\.com|icloud\.com)$/i;
function bridgeAllowedHere() {
try {
if (NO_BRIDGE_HOSTS.test(location.hostname)) return false;
if (localStorage.getItem("aegis:bridge-blocked") === "1") return false;
} catch {}
return true;
}
if (bridgeAllowedHere()) {
try {
let src = mainWorldSource;
// Where Trusted Types exist but are not enforced, a policy keeps the
// assignment clean; where they are enforced with an allow-list the
// policy call itself throws and we fall through to the plain string.
if (window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
try { src = window.trustedTypes.createPolicy("aegis-bridge", { createScript: (x) => x }).createScript(mainWorldSource); } catch {}
}
const s = document.createElement("script");
s.textContent = mainWorldSource;
s.textContent = src;
(document.head || document.documentElement).appendChild(s);
s.remove();
} catch (e) {
console.warn("[aegis] main-world bridge install failed:", e && e.message || e);
try { localStorage.setItem("aegis:bridge-blocked", "1"); } catch {}
console.warn("[aegis] main-world bridge install failed (this origin is now skipped):", e && e.message || e);
}
}