Theseus core:
- addons-host: manifest.category ("plugin") propagates through snapshot(); new
addon API surface checkAndStageSelfUpdate() + restartApp() so a plug-in
can offer in-panel "update now → restart to apply" without pushing the
user to Settings.
- main.js: wires the two new hooks into the AddonHost constructor.
- settings.html: Extensions listing filters out category==="plugin"; those
add-ons live in Plug-ins instead, single source of truth.
Aegis 0.6.31:
- BTC picker trimmed to Signet only; testnet3 hidden (adapter kept so any
existing wallet still loads).
- Wallet strip groups by chain, not chain:network; ticker gets a ▾ chevron
and a dropdown listing every subnetwork with its own totals. Mainnet
reads as the plain ticker; testnets carry a small Chipnet/Signet/Sepolia
pill inline.
- Per-unit price sits directly under the ticker; amount + fiat mirror on
the right — one glance covers name/price/holding/value.
- + Add and ⋯ More promoted from the strip into the header's action row,
next to the new ✎ chip (was the redundant top ⋯). Duplicate "Manage
current wallet" entry removed from the More menu.
- Footer update chip is a two-step flow via the new API: stage → restart.
Falls back to opening Settings on any Theseus that lacks the hooks.
- Manifest declares "category": "plugin".
180 lines
6.4 KiB
JavaScript
180 lines
6.4 KiB
JavaScript
// Fiat prices for every Aegis-supported coin. Opt-in via Settings so a
|
|
// privacy-conscious user isn't quietly telling ANY oracle when Aegis is
|
|
// open. Source is user-selectable — different oracles trade off privacy,
|
|
// coverage, and freshness:
|
|
//
|
|
// - coingecko : one HTTP request covers all 7 coins, best coverage,
|
|
// default. Sees the browser IP + User-Agent every poll.
|
|
// - kraken : per-pair spot from Kraken's public /Ticker; fewer
|
|
// pairs (BCH/BTC/ETH/SOL/TRX; no SC/DGB). Sees IP but
|
|
// no user id.
|
|
// - coinbase : Coinbase's public spot endpoint; similar coverage to
|
|
// Kraken, similar IP-only exposure.
|
|
//
|
|
// New sources plug in by adding an entry to SOURCES. Each provider takes a
|
|
// list of chain keys and returns { <chain>: usd } for the ones it knows
|
|
// about; unknown chains just stay absent from the snapshot. The poller is
|
|
// generic.
|
|
//
|
|
// Cache is in-memory (returned by fullState() → panel). Poll interval is
|
|
// per-source since some rate-limit tighter than others. Off by default.
|
|
|
|
const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"];
|
|
|
|
const SOURCES = {
|
|
coingecko: {
|
|
id: "coingecko",
|
|
label: "CoinGecko",
|
|
origin: "api.coingecko.com",
|
|
pollMs: 5 * 60 * 1000,
|
|
coversAll: true,
|
|
fetch: async () => {
|
|
const ids = {
|
|
bch: "bitcoin-cash", btc: "bitcoin", trx: "tron",
|
|
eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte",
|
|
};
|
|
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(Object.values(ids).join(","))}&vs_currencies=usd`;
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`CoinGecko HTTP ${r.status}`);
|
|
const body = await r.json();
|
|
const out = {};
|
|
for (const [chain, cgId] of Object.entries(ids)) {
|
|
const usd = body?.[cgId]?.usd;
|
|
if (typeof usd === "number") out[chain] = usd;
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
kraken: {
|
|
id: "kraken",
|
|
label: "Kraken",
|
|
origin: "api.kraken.com",
|
|
pollMs: 60 * 1000,
|
|
coversAll: false,
|
|
fetch: async () => {
|
|
// Kraken uses non-standard pair names (XBT, ZUSD…). Only cover the
|
|
// coins Kraken lists with USD spot. SC + DGB are not on Kraken.
|
|
const pairs = { bch: "BCHUSD", btc: "XBTUSD", eth: "ETHUSD", sol: "SOLUSD", trx: "TRXUSD" };
|
|
const url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
|
|
const r = await fetch(url);
|
|
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
|
|
const body = await r.json();
|
|
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
|
|
// Kraken returns keys like "XBCHZUSD" — match by suffix.
|
|
const out = {};
|
|
const result = body?.result || {};
|
|
const entries = Object.entries(result);
|
|
for (const [chain, pair] of Object.entries(pairs)) {
|
|
const hit = entries.find(([k]) => k === pair || k.endsWith(pair) || k.endsWith(pair.replace("XBT", "BT")));
|
|
const last = hit && parseFloat(hit[1]?.c?.[0]);
|
|
if (Number.isFinite(last)) out[chain] = last;
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
coinbase: {
|
|
id: "coinbase",
|
|
label: "Coinbase",
|
|
origin: "api.coinbase.com",
|
|
pollMs: 60 * 1000,
|
|
coversAll: false,
|
|
fetch: async () => {
|
|
// Coinbase publishes one spot per pair via /v2/prices/<pair>/spot.
|
|
// Runs the requests in parallel — 5 calls, each ~150 B response.
|
|
const map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
|
|
const out = {};
|
|
await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
|
|
try {
|
|
const r = await fetch(`https://api.coinbase.com/v2/prices/${pair}/spot`);
|
|
if (!r.ok) return;
|
|
const body = await r.json();
|
|
const usd = parseFloat(body?.data?.amount);
|
|
if (Number.isFinite(usd)) out[chain] = usd;
|
|
} catch { /* one pair failing shouldn't kill the others */ }
|
|
}));
|
|
return out;
|
|
},
|
|
},
|
|
};
|
|
|
|
const DEFAULT_SOURCE = "coingecko";
|
|
|
|
module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
|
|
const state = {
|
|
enabled: false,
|
|
source: DEFAULT_SOURCE,
|
|
prices: {}, // { <chain>: usd (number) }
|
|
fetchedAt: null,
|
|
error: null,
|
|
loading: false,
|
|
};
|
|
let timer = null;
|
|
|
|
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
|
|
|
|
async function fetchOnce() {
|
|
if (!state.enabled) return;
|
|
state.loading = true; state.error = null; onChange();
|
|
try {
|
|
const src = currentProvider();
|
|
const next = await src.fetch();
|
|
state.prices = next || {};
|
|
state.fetchedAt = Date.now();
|
|
state.error = null;
|
|
} catch (e) {
|
|
state.error = e?.message || String(e);
|
|
log(`price fetch (${state.source}) failed:`, state.error);
|
|
} finally {
|
|
state.loading = false;
|
|
onChange();
|
|
}
|
|
}
|
|
|
|
function schedule() {
|
|
clearTimeout(timer);
|
|
if (!state.enabled) return;
|
|
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs);
|
|
}
|
|
|
|
return {
|
|
snapshot() {
|
|
return {
|
|
enabled: state.enabled,
|
|
source: state.source,
|
|
prices: state.prices,
|
|
fetchedAt: state.fetchedAt,
|
|
error: state.error,
|
|
loading: state.loading,
|
|
sources: Object.values(SOURCES).map((s) => ({
|
|
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll,
|
|
})),
|
|
};
|
|
},
|
|
// Turn the feed on/off. Enabling triggers an immediate fetch so the
|
|
// panel doesn't wait a full poll interval for the first price.
|
|
async setEnabled(on) {
|
|
const changed = !!on !== state.enabled;
|
|
state.enabled = !!on;
|
|
if (!state.enabled) {
|
|
state.prices = {}; state.fetchedAt = null; state.error = null;
|
|
clearTimeout(timer);
|
|
if (changed) onChange();
|
|
return;
|
|
}
|
|
onChange();
|
|
await fetchOnce();
|
|
schedule();
|
|
},
|
|
// Switch source. Clears the current cache, kicks a fresh fetch if the
|
|
// feed is enabled. No-op when the source is already current.
|
|
async setSource(id) {
|
|
if (!SOURCES[id] || id === state.source) return;
|
|
state.source = id;
|
|
state.prices = {}; state.fetchedAt = null;
|
|
onChange();
|
|
if (state.enabled) { await fetchOnce(); schedule(); }
|
|
},
|
|
refresh() { return fetchOnce(); },
|
|
dispose() { clearTimeout(timer); state.enabled = false; },
|
|
};
|
|
};
|