diff --git a/bundled-addons/aegis/addon.json b/bundled-addons/aegis/addon.json index ce07ff29..7c8edf0f 100644 --- a/bundled-addons/aegis/addon.json +++ b/bundled-addons/aegis/addon.json @@ -1,7 +1,7 @@ { "id": "aegis", "name": "Aegis Wallet", - "version": "0.8.8", + "version": "0.8.9", "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.", "author": "Silent Mode", diff --git a/bundled-addons/aegis/lib/chain-generic-imported.js b/bundled-addons/aegis/lib/chain-generic-imported.js index c267a42b..b52419c3 100644 --- a/bundled-addons/aegis/lib/chain-generic-imported.js +++ b/bundled-addons/aegis/lib/chain-generic-imported.js @@ -1,243 +1,389 @@ -// Generic single-address read-only imported adapter for account-model -// chains. One config-driven runtime handles ETH-family, Tron, and Solana -// balance polling — every chain differs only in the RPC verb and the -// JSON path to the balance number. -// -// The adapter mirrors the public shape every Aegis chain runtime exposes -// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet -// stays chain-agnostic. planSend/send throw a "read-only" error until -// M.1b delivers the sign path per chain. - -module.exports = function makeGenericImportedAdapter() { - - // base58check T… -> 41-prefixed hex, for comparing against the raw - // owner_address/to_address fields the /v1 tx feed returns. - const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - function tronAddrToHex(b58) { - try { - let n = 0n; - for (const ch of String(b58)) { - const i = B58.indexOf(ch); - if (i < 0) return ""; - n = n * 58n + BigInt(i); - } - let hex = n.toString(16); - if (hex.length % 2) hex = "0" + hex; - // 25 bytes = 21 payload + 4 checksum; drop the checksum. - return hex.padStart(50, "0").slice(0, 42); - } catch { return ""; } - } - - const CHAIN_CFGS = { - eth: { - ticker: "ETH", decimals: 18, - networks: { - mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, - sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" }, - }, - // JSON-RPC eth_getBalance → hex-string wei. - async fetchBalance({ rpc, address }) { - const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) }); - const j = await r.json(); - const hex = String(j?.result || "0x0").replace(/^0x/, ""); - return BigInt("0x" + hex).toString(); - }, - }, - trx: { - ticker: "TRX", decimals: 6, - networks: { - mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" }, - // nile.trongrid.io, NOT api.nileex.io: nileex only serves the - // /wallet/* JSON-RPC family and 404s the whole /v1/ REST family, - // which is where transaction history and the trc20 token list - // live. Balance worked, everything else silently came back empty. - nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" }, - }, - // Tron HTTP API returns account.balance in SUN (10^-6 TRX). - async fetchBalance({ rpc, address }) { - const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ address, visible: true }) }); - const j = await r.json(); - return String(j?.balance || 0); - }, - async fetchHistory({ 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(); - const list = Array.isArray(j?.data) ? j.data : []; - return list.map((t) => { - const c = t?.raw_data?.contract?.[0]; - const v = c?.parameter?.value || {}; - const ownerHex = String(v.owner_address || ""); - // owner/to come back as 41-prefixed hex regardless of visible. - const mineHex = tronAddrToHex(address); - const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase(); - const amount = Number(v.amount || 0); - const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true; - return { - txid: t.txID || t.txid, - time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000), - confirmations: ok ? 1 : 0, - status: ok ? "confirmed" : "failed", - // Aegis renders `delta` in the wallet's base unit (sun here). - delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0, - kind: c?.type || "Contract", - }; - }).filter((t) => t.txid); - }, - // TRC20 balances live on the /v1 REST family. The balance map is - // contract -> raw amount with no symbol/decimals, so we join it - // against token_info from recent transfers to name what we can. - async fetchTokens({ rpc, address }) { - const base = rpc.replace(/\/+$/, ""); - const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`); - if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`); - const j = await r.json(); - const acct = Array.isArray(j?.data) ? j.data[0] : j?.data; - const raw = Array.isArray(acct?.trc20) ? acct.trc20 : []; - const balances = new Map(); - for (const entry of raw) { - for (const [contract, amt] of Object.entries(entry || {})) { - if (String(amt) !== "0") balances.set(contract, String(amt)); - } - } - if (!balances.size) return []; - const info = new Map(); - try { - const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`); - if (tr.ok) { - const tj = await tr.json(); - for (const t of (Array.isArray(tj?.data) ? tj.data : [])) { - const ti = t?.token_info; - if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti); - } - } - } catch { /* names are a nicety; balances still render */ } - // Named tokens first: an address that's been airdrop-spammed can - // hold dozens of contracts we have no token_info for, and those - // would otherwise bury the ones the user actually cares about. - return Array.from(balances, ([contract, balance]) => { - const ti = info.get(contract); - return { - mint: contract, - symbol: ti?.symbol || "?", - name: ti?.name || "", - decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, - known: !!ti, - balance, - }; - }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")); - }, - }, - sol: { - ticker: "SOL", decimals: 9, - networks: { - mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" }, - devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" }, - }, - // Solana JSON-RPC getBalance returns lamports as a number. - async fetchBalance({ rpc, address }) { - const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) }); - const j = await r.json(); - return String(j?.result?.value || 0); - }, - }, - }; - - class GenericImportedWallet { - constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) { - const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`); - const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`); - if (!address) throw new Error("address required"); - this.chain = chain; - this.network = network; - this._cfg = cfg; - this._net = { ...net, rpc: rpcUrl || net.rpc }; - this.log = log; - this.onChange = onChange; - this._address = address; - this._state = { - balance: { confirmed: "0", unconfirmed: "0" }, - history: [], - tokens: [], - scanning: false, - error: null, - }; - this._pollTimer = null; - } - - setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ } - schedulePoll(ms) { - clearTimeout(this._pollTimer); - this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms); - } - - _emit() { try { this.onChange(); } catch {} } - - snapshot() { - return { - chain: this.chain, network: this.network, - ticker: this._cfg.ticker, decimals: this._cfg.decimals, - address: this._address, - addressIndex: 0, - addressPath: null, - balance: this._state.balance, - history: this._state.history, - tokens: this._state.tokens, - scanning: this._state.scanning, - error: this._state.error, - server: this._net.rpc, - rpcUrl: this._net.rpc, - imported: true, - explorerAddr: this._net.explorerAddr, - explorerTx: this._net.explorerTx, - explorerSuffix: this._net.explorerSuffix || "", - faucet: this._net.faucet || null, - }; - } - - async refresh() { - this._state.scanning = true; this._emit(); - const opts = { rpc: this._net.rpc, address: this._address }; - try { - // Only the balance is load-bearing — history and tokens are - // best-effort so one 404 on a chain that has no keyless feed - // doesn't blank the wallet. - const [confirmed, history, tokens] = await Promise.all([ - this._cfg.fetchBalance(opts), - this._cfg.fetchHistory - ? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; }) - : Promise.resolve(null), - this._cfg.fetchTokens - ? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; }) - : Promise.resolve(null), - ]); - this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" }; - if (Array.isArray(history)) this._state.history = history; - if (Array.isArray(tokens)) this._state.tokens = tokens; - this._state.error = null; - } catch (e) { - this._state.error = e?.message || String(e); - } finally { - this._state.scanning = false; - this._emit(); - } - } - - nextAddress() { return { address: this._address, index: 0 }; } - current() { return { address: this._address, index: 0, branch: 0, path: null }; } - - plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); } - signAndBroadcast() { throw new Error("read-only"); } - signMessage() { throw new Error("read-only"); } - - recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; } - - dispose() { clearTimeout(this._pollTimer); } - } - - return { GenericImportedWallet, CHAIN_CFGS }; -}; +// Generic single-address read-only imported adapter for account-model +// chains. One config-driven runtime handles ETH-family, Tron, and Solana +// balance polling — every chain differs only in the RPC verb and the +// JSON path to the balance number. +// +// The adapter mirrors the public shape every Aegis chain runtime exposes +// (snapshot, refresh, plan, signAndBroadcast, dispose) so mountWallet +// stays chain-agnostic. planSend/send throw a "read-only" error until +// M.1b delivers the sign path per chain. + +module.exports = function makeGenericImportedAdapter() { + + // base58check T… -> 41-prefixed hex, for comparing against the raw + // owner_address/to_address fields the /v1 tx feed returns. + const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + function tronAddrToHex(b58) { + try { + let n = 0n; + for (const ch of String(b58)) { + const i = B58.indexOf(ch); + if (i < 0) return ""; + n = n * 58n + BigInt(i); + } + let hex = n.toString(16); + if (hex.length % 2) hex = "0" + hex; + // 25 bytes = 21 payload + 4 checksum; drop the checksum. + return hex.padStart(50, "0").slice(0, 42); + } catch { return ""; } + } + + // Airdrop spam is the norm on public addresses — a real test address came + // back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a + // sidebar is useless, so every fetchTokens caps its list. Sorting puts + // named/known tokens first, so the cap drops spam before it drops + // anything the user recognises. + const TOKEN_CAP = 50; + + // Token names are attacker-controlled. Scam mints ship symbols that are + // blank, pure whitespace, zero-width characters, or carry bidi overrides + // to make one string render as another. Strip the invisible classes, cap + // the length, and return "" when nothing legible survives so the caller + // can mark the token unknown instead of rendering an empty-looking row + // that borrows trust from the ones above it. + // Ranges are listed numerically rather than as a regex character class on + // purpose: a literal class would need these very characters in the source, + // where they are invisible to a reviewer and easy for an editor or a patch + // tool to mangle. + const INVISIBLE_RANGES = [ + [0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls + [0x200b, 0x200f], // zero-width space..RTL mark + [0x202a, 0x202e], // bidi embedding / override + [0x2060, 0x206f], // word joiner, invisible operators + [0xfeff, 0xfeff], // BOM / zero-width no-break space + ]; + function cleanTokenText(s) { + let out = ""; + for (const ch of String(s == null ? "" : s)) { + const cp = ch.codePointAt(0); + if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue; + out += ch; + } + return out.replace(/\s+/g, " ").trim().slice(0, 32); + } + + const CHAIN_CFGS = { + eth: { + ticker: "ETH", decimals: 18, + networks: { + // `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints + // above serve balances but have no history or token concept at all — + // that's why imported ETH wallets showed a balance and nothing else. + // Etherscan V2 would need an API key; Blockscout does not. + // publicnode, not llamarpc: llamarpc was answering 525 with an HTML + // error page, which surfaced as a JSON parse error and a 0 balance. + mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://ethereum-rpc.publicnode.com", indexer: "https://eth.blockscout.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" }, + sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", indexer: "https://eth-sepolia.blockscout.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" }, + }, + // JSON-RPC eth_getBalance → hex-string wei. + async fetchBalance({ rpc, address }) { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }) }); + const j = await r.json(); + const hex = String(j?.result || "0x0").replace(/^0x/, ""); + return BigInt("0x" + hex).toString(); + }, + async fetchHistory({ address, net }) { + if (!net?.indexer) return null; + const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } }); + if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`); + const j = await r.json(); + const items = Array.isArray(j?.items) ? j.items : []; + const me = String(address).toLowerCase(); + return items.slice(0, 25).map((t) => { + const from = String(t.from?.hash || "").toLowerCase(); + const wei = BigInt(String(t.value || "0")); + const outgoing = from === me; + // A mempool tx comes back as {result:"pending", status:null, + // timestamp:null}. Reading that as `status !== "ok" → failed` + // showed pending sends as failures, which is the one thing a + // wallet must never get wrong. + const pending = t.result === "pending" || t.status == null; + return { + txid: t.hash, + time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0, + confirmations: Number(t.confirmations) || 0, + status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"), + // Keep wei exact — 18 decimals overflows a JS number. + delta: (outgoing ? -wei : wei).toString(), + kind: t.method || "Transfer", + }; + }).filter((t) => t.txid); + }, + async fetchTokens({ address, net }) { + if (!net?.indexer) return null; + const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } }); + if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`); + const j = await r.json(); + const list = Array.isArray(j) ? j : []; + return list.map((e) => { + const t = e?.token || {}; + const symbol = cleanTokenText(t.symbol); + return { + mint: t.address_hash || t.address || "", + symbol: symbol || "?", + name: cleanTokenText(t.name), + decimals: Number(t.decimals) || 0, + known: !!symbol, + balance: String(e.value ?? "0"), + }; + }).filter((t) => t.mint && t.balance !== "0") + .sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")) + .slice(0, TOKEN_CAP); + }, + }, + trx: { + ticker: "TRX", decimals: 6, + networks: { + mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" }, + // nile.trongrid.io, NOT api.nileex.io: nileex only serves the + // /wallet/* JSON-RPC family and 404s the whole /v1/ REST family, + // which is where transaction history and the trc20 token list + // live. Balance worked, everything else silently came back empty. + nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" }, + }, + // Tron HTTP API returns account.balance in SUN (10^-6 TRX). + async fetchBalance({ rpc, address }) { + const r = await fetch(rpc.replace(/\/+$/, "") + "/wallet/getaccount", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ address, visible: true }) }); + const j = await r.json(); + return String(j?.balance || 0); + }, + async fetchHistory({ 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(); + const list = Array.isArray(j?.data) ? j.data : []; + return list.map((t) => { + const c = t?.raw_data?.contract?.[0]; + const v = c?.parameter?.value || {}; + const ownerHex = String(v.owner_address || ""); + // owner/to come back as 41-prefixed hex regardless of visible. + const mineHex = tronAddrToHex(address); + const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase(); + const amount = Number(v.amount || 0); + const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true; + return { + txid: t.txID || t.txid, + time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000), + confirmations: ok ? 1 : 0, + status: ok ? "confirmed" : "failed", + // Aegis renders `delta` in the wallet's base unit (sun here). + delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0, + kind: c?.type || "Contract", + }; + }).filter((t) => t.txid); + }, + // TRC20 balances live on the /v1 REST family. The balance map is + // contract -> raw amount with no symbol/decimals, so we join it + // against token_info from recent transfers to name what we can. + async fetchTokens({ rpc, address }) { + const base = rpc.replace(/\/+$/, ""); + const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`); + if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`); + const j = await r.json(); + const acct = Array.isArray(j?.data) ? j.data[0] : j?.data; + const raw = Array.isArray(acct?.trc20) ? acct.trc20 : []; + const balances = new Map(); + for (const entry of raw) { + for (const [contract, amt] of Object.entries(entry || {})) { + if (String(amt) !== "0") balances.set(contract, String(amt)); + } + } + if (!balances.size) return []; + const info = new Map(); + try { + const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`); + if (tr.ok) { + const tj = await tr.json(); + for (const t of (Array.isArray(tj?.data) ? tj.data : [])) { + const ti = t?.token_info; + if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti); + } + } + } catch { /* names are a nicety; balances still render */ } + // Named tokens first: an address that's been airdrop-spammed can + // hold dozens of contracts we have no token_info for, and those + // would otherwise bury the ones the user actually cares about. + return Array.from(balances, ([contract, balance]) => { + const ti = info.get(contract); + const symbol = cleanTokenText(ti?.symbol); + return { + mint: contract, + symbol: symbol || "?", + name: cleanTokenText(ti?.name), + decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0, + known: !!ti && !!symbol, + balance, + }; + }).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || "")) + .slice(0, TOKEN_CAP); + }, + }, + sol: { + ticker: "SOL", decimals: 9, + networks: { + mainnet: { id: "mainnet", label: "Mainnet-beta", rpc: "https://api.mainnet-beta.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/" }, + devnet: { id: "devnet", label: "Devnet", rpc: "https://api.devnet.solana.com", explorerAddr: "https://explorer.solana.com/address/", explorerTx: "https://explorer.solana.com/tx/", explorerSuffix: "?cluster=devnet", testnet: true, faucet: "https://faucet.solana.com/" }, + }, + // Solana JSON-RPC getBalance returns lamports as a number. + async fetchBalance({ rpc, address }) { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getBalance", params: [address] }) }); + const j = await r.json(); + return String(j?.result?.value || 0); + }, + // getSignaturesForAddress is keyless on the public RPC. It gives us + // the ledger of signatures touching this address but NOT the amounts — + // that would need a getTransaction per signature (25 extra round trips + // on every poll). We surface the entries with a null delta so the user + // at least sees activity and can open any of them in the explorer. + async fetchHistory({ rpc, address }) { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getSignaturesForAddress", params: [address, { limit: 25 }] }) }); + if (!r.ok) throw new Error(`Solana history HTTP ${r.status}`); + const j = await r.json(); + if (j?.error) throw new Error(j.error.message || "getSignaturesForAddress failed"); + const list = Array.isArray(j?.result) ? j.result : []; + return list.map((s) => ({ + txid: s.signature, + time: Number(s.blockTime) || 0, + confirmations: s.confirmationStatus === "finalized" ? 1 : 0, + status: s.err ? "failed" : "confirmed", + delta: null, + kind: "Transaction", + })).filter((t) => t.txid); + }, + // SPL balances via getTokenAccountsByOwner with jsonParsed, matching + // what the built-in Solana adapter does. Symbol/name aren't on-chain + // in the token account, so the mint stands in for the symbol. + async fetchTokens({ rpc, address }) { + const SPL = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const SPL22 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + const call = async (programId) => { + const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner", + params: [address, { programId }, { encoding: "jsonParsed" }] }) }); + if (!r.ok) throw new Error(`Solana tokens HTTP ${r.status}`); + const j = await r.json(); + if (j?.error) throw new Error(j.error.message || "getTokenAccountsByOwner failed"); + return Array.isArray(j?.result?.value) ? j.result.value : []; + }; + const accounts = [].concat(...await Promise.all([ + call(SPL).catch(() => []), + call(SPL22).catch(() => []), + ])); + const out = []; + for (const a of accounts) { + const info = a?.account?.data?.parsed?.info; + const amt = info?.tokenAmount; + if (!info?.mint || !amt || String(amt.amount) === "0") continue; + out.push({ + mint: String(info.mint), + symbol: String(info.mint).slice(0, 4) + "…", + name: "", + decimals: Number(amt.decimals) || 0, + known: true, // decimals ARE on-chain here, so the amount is real + balance: String(amt.amount), + }); + } + return out.sort((a, b) => (a.mint || "").localeCompare(b.mint || "")).slice(0, TOKEN_CAP); + }, + }, + }; + + class GenericImportedWallet { + constructor({ chain, network, address, log = () => {}, onChange = () => {}, rpcUrl } = {}) { + const cfg = CHAIN_CFGS[chain]; if (!cfg) throw new Error(`chain-generic-imported: unknown chain ${chain}`); + const net = cfg.networks[network]; if (!net) throw new Error(`chain-generic-imported: ${chain} has no network ${network}`); + if (!address) throw new Error("address required"); + this.chain = chain; + this.network = network; + this._cfg = cfg; + this._net = { ...net, rpc: rpcUrl || net.rpc }; + this.log = log; + this.onChange = onChange; + this._address = address; + this._state = { + balance: { confirmed: "0", unconfirmed: "0" }, + history: [], + tokens: [], + scanning: false, + error: null, + }; + this._pollTimer = null; + } + + setServers() { /* no-op: this adapter uses HTTP RPC, not electrum */ } + schedulePoll(ms) { + clearTimeout(this._pollTimer); + this._pollTimer = setTimeout(() => { this.refresh(false).catch(() => {}); this.schedulePoll(ms); }, ms); + } + + _emit() { try { this.onChange(); } catch {} } + + snapshot() { + return { + chain: this.chain, network: this.network, + ticker: this._cfg.ticker, decimals: this._cfg.decimals, + address: this._address, + addressIndex: 0, + addressPath: null, + balance: this._state.balance, + history: this._state.history, + tokens: this._state.tokens, + scanning: this._state.scanning, + error: this._state.error, + server: this._net.rpc, + rpcUrl: this._net.rpc, + imported: true, + explorerAddr: this._net.explorerAddr, + explorerTx: this._net.explorerTx, + explorerSuffix: this._net.explorerSuffix || "", + faucet: this._net.faucet || null, + }; + } + + async refresh() { + this._state.scanning = true; this._emit(); + const opts = { rpc: this._net.rpc, address: this._address, net: this._net }; + try { + // Only the balance is load-bearing — history and tokens are + // best-effort so one 404 on a chain that has no keyless feed + // doesn't blank the wallet. + const [confirmed, history, tokens] = await Promise.all([ + this._cfg.fetchBalance(opts), + this._cfg.fetchHistory + ? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; }) + : Promise.resolve(null), + this._cfg.fetchTokens + ? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; }) + : Promise.resolve(null), + ]); + this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" }; + if (Array.isArray(history)) this._state.history = history; + if (Array.isArray(tokens)) this._state.tokens = tokens; + this._state.error = null; + } catch (e) { + this._state.error = e?.message || String(e); + } finally { + this._state.scanning = false; + this._emit(); + } + } + + nextAddress() { return { address: this._address, index: 0 }; } + current() { return { address: this._address, index: 0, branch: 0, path: null }; } + + plan() { throw new Error(`Imported ${this.chain.toUpperCase()} wallets are read-only in this build. Spending support ships in the next Aegis update.`); } + signAndBroadcast() { throw new Error("read-only"); } + signMessage() { throw new Error("read-only"); } + + recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import." }; } + + dispose() { clearTimeout(this._pollTimer); } + } + + return { GenericImportedWallet, CHAIN_CFGS }; +}; diff --git a/bundled-addons/aegis/panel.html b/bundled-addons/aegis/panel.html index 0c7a6a80..da094776 100644 --- a/bundled-addons/aegis/panel.html +++ b/bundled-addons/aegis/panel.html @@ -479,10 +479,17 @@ filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); } #lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; } #lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; } - #lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: left; } + /* The form inherits the lock screen's centred alignment — it used to + force text-align:left, which left the setup screen's helper copy + running ragged against a centred title, mark and description. */ + #lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: center; } #lockScreen .lockform input[type=password], - #lockScreen .lockform input[type=text], - #lockScreen .lockform textarea { text-align: center; } + #lockScreen .lockform input[type=text] { text-align: center; } + /* The mnemonic stays left-aligned on purpose: 12/24 words wrap across + several lines, and centring makes them ragged on both edges, which is + exactly the wrong thing when someone is checking a seed word by word. */ + #lockScreen .lockform textarea { text-align: left; } + #lockScreen .lockform .hint { text-align: center; } #lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; } #lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; } #lockScreen .altline a:hover { text-decoration: underline; } diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index d4fa9faf..6df30791 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -3225,17 +3225,35 @@ function renderHistory() { const list = s?.history || []; const el = $("txlist"); if (!list.length) { el.innerHTML = `
${s?.scanning ? "Syncing…" : "No transactions yet."}
`; return; } + const dec = s?.decimals ?? 8; el.innerHTML = list.map((t) => { - const inc = t.delta >= 0; + // `delta` arrives in three shapes now: a number (UTXO chains), a decimal + // STRING (ETH — 18 decimals of wei overflows a JS number, so it must + // stay exact), or null (Solana, where the signature feed carries no + // amount and fetching one per tx would be 25 extra round trips a poll). + // Treating null as 0 would render "+ —", claiming a receive we cannot + // actually verify, so unknown amounts get their own neutral branch. + const known = t.delta != null; + const neg = known && String(t.delta).trim().startsWith("-"); + const inc = known ? !neg : null; const when = t.time ? new Date(t.time * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }) : "pending"; - const who = inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); - const what = (inc ? "Received" : "Sent") + (who ? " " + who : ""); - const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") : (t.status === "failed" ? "failed" : "unconfirmed"); - const delta = Math.abs(t.delta || 0); + const who = inc === null ? "" : inc ? (t.from ? "from " + shortAddr(t.from) : "") : (t.to ? "to " + shortAddr(t.to) : ""); + const what = (inc === null ? (t.kind || "Transaction") : inc ? "Received" : "Sent") + (who ? " " + who : ""); + const conf = t.confirmations > 0 ? (t.confirmations >= 6 ? "confirmed" : t.confirmations + " conf") + : (t.status === "failed" ? "failed" : t.status === "pending" ? "pending" : "unconfirmed"); + // Strip the sign as text rather than via Math.abs so a big-decimal + // string keeps every digit. + const magnitude = known ? String(t.delta).trim().replace(/^[-+]/, "") : ""; + const isZero = known && /^0*$/.test(magnitude); + const amountHtml = !known ? "—" + : isZero ? "—" + : `${inc ? "+" : "−"}${esc(fmtBig(magnitude, dec))}`; + const icon = inc === null ? "·" : inc ? "↓" : "↑"; + const iconCls = inc === null ? "" : inc ? "in" : "out"; return `
-
${inc ? "↓" : "↑"}
+
${icon}
${esc(what)}
-
${inc ? "+" : "−"}${delta ? fmtBig(delta) : "—"}
+
${amountHtml}
${esc(when)}${t.fee != null ? " · fee " + fmtSmall(t.fee) + " " + smallUnitLabel() : ""}
${esc(conf)}
`;