theseus/bundled-addons/aegis/wallet-inject.js
Local Dev 884f3948b8 Aegis: fees that follow the network, connections that come back, its own name on Solana
Still 0.31.0 (unpublished batch).

- ETH nonce. The pending count from a load-balanced RPC often misses a
  transaction this wallet sent seconds ago, so two sends in a row shared a
  nonce and the second failed or replaced the first. The nonce is taken at
  signing, from the RPC or from what the wallet itself last broadcast,
  whichever is higher, and broadcasts are serialised per wallet.
- Chains without EIP-1559 (no baseFeePerGas) get a legacy EIP-155
  transaction; they rejected the type-2 envelope, so a network added by a
  dapp could receive but never send. The tip is clamped to the fee cap.
- Bitcoin and DigiByte take their fee rate from the Electrum server's
  estimate instead of a constant, size each output from its real script
  (a taproot destination was undercounted), and round the size up before
  pricing. Bitcoin inputs signal replace-by-fee. DigiByte keeps its 20
  sat/vB floor and does not signal RBF, which it does not have.
- Electrum: a wallet with live subscriptions went quiet for good when its
  server dropped. The client reconnects with backoff, pings to catch dead
  sockets, times out a silent connect, and hands the replayed subscription
  answers on as notifications so the wallet refreshes. dispose() ends it.
- Max with an SPL token selected did nothing; it now fills the exact token
  balance.
- Removing a wallet retired its derivation index for good. Add wallet now
  takes the lowest free index, so the same wallet comes back.
- Solana: registered through the Wallet Standard as "Aegis" instead of
  setting isPhantom, with silent connect for already-connected sites.
- "Only show the wallet to sites I enable": Aegis keeps the host's
  page-inject allow-list in step with enabled and connected sites.
2026-10-04 01:55:38 +02:00

729 lines
36 KiB
JavaScript

// Aegis dapp bridges. Runs in the isolated world of every matching tab and
// puts up to two dapp APIs on the page's main world:
//
// window.bitcoincash Silent Mode's own BCH API (only on .x pages —
// matches the pre-multi-wallet gate).
// window.tronWeb TronLink-shaped Tron dapp bridge (on any page).
// window.tronLink TronLink shim; `tron_requestAccounts` triggers
// an approval overlay via activate().
//
// Everything crosses into the wallet's activate() context in main, which
// shows the approval overlay and enforces per-origin permissions. Nothing
// here can sign, read a balance, or read an address on its own.
//
// `theseus` is provided by the host: { id, origin, contextBridge, invoke }.
const call = (msg, payload) =>
theseus.invoke(msg, payload).catch((e) => {
const text = String(e && e.message || e).replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
throw new Error(text);
});
// -------- BCH bridge (window.bitcoincash), 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",
network: "mainnet",
getAddress: () => call("getAddress"),
signAndSend: (txSpec) => call("signAndSend", txSpec && typeof txSpec === "object" ? txSpec : {}),
signMessage: (message) => call("signMessage", { message: String(message ?? "") }),
});
// -------- Tron bridge (window.tronWeb, window.tronLink), everywhere ---------
//
// Matches enough of the TronLink surface for read + send flows on dapps like
// tronscan, sunswap, and justlend (mainnet or Nile). Not implemented:
// TRC20/TRC721 abstractions, address utilities beyond base58<->hex, event
// emitters beyond the "message" events TronLink documents.
let CACHED = { address: null, network: null };
const listeners = new Set();
// Public copy of tronWeb.trx.
const trx = {
// Returns the account object TronGrid gives (may be {} for never-funded).
// Deliberately mirrors tronWeb.trx.getAccount.
getAccount: async () => call("trx.getAccount"),
// Sign an assembled transaction (dapp built it via its own tronWeb).
// Returns the same object with a `signature` field populated.
sign: async (transaction) => {
if (!transaction || typeof transaction !== "object" || !transaction.raw_data_hex) {
throw new Error("tronWeb.trx.sign: expected an assembled transaction with raw_data_hex");
}
return call("trx.signTransaction", { transaction });
},
// Broadcast a signed transaction. Returns { result: true, txid } on success.
sendRawTransaction: async (signedTx) => {
const r = await call("trx.sendRawTransaction", { transaction: signedTx });
return { result: true, txid: r.txid };
},
// Convenience wrappers matching the tronWeb surface — dapps call these
// when the wallet is supposed to build the tx too. Left unimplemented
// in this build so a dapp that hits them gets a clear error instead of
// silently unassembled behavior; assemble on the dapp side.
sendTrx: async () => { throw new Error("tronWeb.trx.sendTrx not implemented — build the transaction with tronWeb.transactionBuilder and call sign+sendRawTransaction"); },
// TronLink documents signMessage (legacy, keccak of just the message) and
// signMessageV2 (adds the "\x19TRON Signed Message:\n<len>" prefix). We
// only implement V2 because that is what modern dapps use.
signMessageV2: async (message) => {
const r = await call("trx.signMessageV2", { message: String(message ?? "") });
return r.signature;
},
};
// Shallow tronWeb — enough for `tronWeb.defaultAddress.base58` / `.hex` reads
// and the trx methods. Populated once the user connects.
const tronWeb = {
ready: false,
defaultAddress: { base58: false, hex: false, name: false },
fullNode: { host: "" },
solidityNode: { host: "" },
eventServer: { host: "" },
trx,
// Utility helpers a few dapps poke at.
isConnected: () => tronWeb.ready === true && !!tronWeb.defaultAddress.base58,
address: {
fromHex: (hex) => hex, // no-op stub; dapps that need a real
toHex: (base58) => base58, // conversion should use their own copy.
},
// Some dapps read this to decide "Tron mainnet vs testnet".
get currentNetwork() { return CACHED.network || null; },
};
function nodeHost(network) {
return network === "nile" ? "https://nile.trongrid.io" : "https://api.trongrid.io";
}
function applyConnection({ address, network }) {
CACHED = { address, network };
tronWeb.defaultAddress = { base58: address || false, hex: false, name: false };
tronWeb.ready = !!address;
tronLink.ready = !!address;
const host = nodeHost(network);
tronWeb.fullNode = { host };
tronWeb.solidityNode = { host };
tronWeb.eventServer = { host };
// Broadcast on the window in the way TronLink documents.
try {
window.postMessage({ message: { action: "accountsChanged", data: { address } }, isTronLink: true }, location.origin);
window.postMessage({ message: { action: "setAccount", data: { address } }, isTronLink: true }, location.origin);
window.postMessage({ message: { action: "setNode", data: { node: { fullNode: host, solidityNode: host, eventServer: host, chain: network === "nile" ? "0xcd8690dc" : "0x2b6653dc" } } }, isTronLink: true }, location.origin);
} catch {}
for (const fn of listeners) { try { fn({ address, network }); } catch {} }
}
const tronLink = {
ready: false,
tronWeb: tronWeb,
// TronLink's canonical request router. Handles at minimum tron_requestAccounts.
request: async (opts) => {
const method = String(opts?.method || "");
if (method === "tron_requestAccounts" || method === "requestAccounts") {
const r = await call("trx.requestAccounts");
applyConnection({ address: r.address, network: r.network });
return { code: 200, message: "ok" };
}
if (method === "tron_accounts") {
const r = await call("trx.getAccount").catch(() => null);
return r ? [r.address] : [];
}
throw new Error("Aegis: unsupported tronLink method " + method);
},
// Dapps sometimes attach here to react to wallet changes.
on: (event, fn) => {
if (event === "message" || event === "accountsChanged" || event === "networkChanged") listeners.add(fn);
},
removeListener: (_event, fn) => listeners.delete(fn),
};
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
// -------- WizardConnect bridge (window.wizardconnect), everywhere -----------
//
// WizardConnect is a Nostr-relay pairing protocol built for CROSS-device use:
// the dapp calls initiateDappRelay(), gets a wiz:// uri, and renders it as a
// QR for a phone wallet to scan. On the same device that QR round-trip is
// pure friction — the dapp and Aegis are in the same browser.
//
// The SDK has no in-page wallet discovery, so this is Silent Mode's own
// surface. A dapp keeps its existing initiateDappRelay() call and simply
// hands us the uri it already generated:
//
// const { uri } = initiateDappRelay(onStatus);
// if (window.wizardconnect) await window.wizardconnect.connect(uri);
// else renderQr(uri); // unchanged fallback
//
// connect() resolves once the user approves the pairing in Aegis and the
// key exchange completes, and rejects if they decline. Nothing is paired
// without an explicit approval, and we never read the page to find a uri —
// the dapp hands it to us.
const wizardconnect = {
isAegis: true,
version: "1.0.0",
// Present so a dapp can tell "wallet is installed" from "wallet is
// installed but has no BCH wallet ready to pair with" before it decides
// whether to fall back to a QR.
isReady: () => call("wcPageReady"),
connect: (uri) => call("wcConnectFromPage", { uri: String(uri ?? "") }),
};
theseus.contextBridge.exposeInMainWorld("wizardconnect", wizardconnect);
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
//
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
// object to touch the dapp's own JS values — Solana in particular passes
// `Transaction` instances whose `.serializeMessage()` method the wallet has
// to call. Electron's contextBridge shallow-copies function arguments
// between worlds and strips methods, so bridges that need to call methods
// on dapp-side objects have to live in the main world. We inject a `<script>`
// with the bridge source; it runs synchronously in the main world and talks
// to us via `window.postMessage` on a namespaced envelope. This mirrors how
// MetaMask and Phantom bridge extension code back to page code.
// Namespace used on the postMessage envelope. Includes the addon id plus a
// per-page-load nonce: only the main-world bridge we install below knows
// it, so a cross-origin iframe on the page cannot address the relay. The
// listener additionally requires the event to come from this very window
// (e.source === window) — an iframe's postMessage to its parent has
// e.source === that iframe — so a request can never be attributed to the
// top-level origin by anything but the top-level page's own scripts. The
// old fixed tag let any embedded ad frame call the wallet as the page.
const AEGIS_TAG = "aegis-" + theseus.id + "-" + Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, "0")).join("");
const pendingCalls = new Map();
window.addEventListener("message", async (e) => {
if (!e || e.source !== window || e.origin !== location.origin) return;
const d = e.data;
if (!d || typeof d !== "object" || d.aegisTag !== AEGIS_TAG) return;
if (d.kind === "request") {
// Forward main-world → isolated-world → addon.
try {
const result = await call(d.msg, d.payload);
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: true, result }, location.origin);
} catch (err) {
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: false, error: String(err && err.message || err) }, location.origin);
}
}
});
function emitToMainWorld(event, data) {
try { window.postMessage({ aegisTag: AEGIS_TAG, kind: "event", event, data }, location.origin); } catch {}
}
// The main-world bridge — installed as a page-level `<script>` so it can
// call methods on Transaction objects the dapp hands it, and so the globals
// it defines look like ordinary page code to the dapp.
const mainWorldSource = `(function () {
if (window.__aegisBridge) return;
window.__aegisBridge = true;
const TAG = ${JSON.stringify(AEGIS_TAG)};
const pending = new Map();
let seq = 1;
function invoke(msg, payload) {
const id = "r" + (seq++);
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.postMessage({ aegisTag: TAG, kind: "request", id, msg, payload }, location.origin);
});
}
window.addEventListener("message", (e) => {
if (!e || e.source !== window) return;
const d = e.data;
if (!d || typeof d !== "object" || d.aegisTag !== TAG) return;
if (d.kind === "response") {
const p = pending.get(d.id);
if (!p) return;
pending.delete(d.id);
d.ok ? p.resolve(d.result) : p.reject(new Error(d.error));
} else if (d.kind === "event") {
dispatchEvent(d.event, d.data);
}
});
// ---- Ethereum (EIP-1193) ------------------------------------------------
const ethListeners = { connect: [], disconnect: [], accountsChanged: [], chainChanged: [], message: [] };
let ethState = { address: null, chainIdHex: "0x1", networkVersion: "1" };
function ethEmit(event, data) {
for (const fn of ethListeners[event] || []) { try { fn(data); } catch {} }
}
// After a chain switch or add, refresh the local chainId/address state
// and fire the two events dapps expect (accountsChanged +
// chainChanged). MetaMask does the same round-trip after switchChain.
async function pullEthStateAndEmit() {
try {
const s = await invoke("eth.state");
const oldChain = ethState.chainIdHex, oldAddr = ethState.address;
ethState.address = s.address || null;
ethState.chainIdHex = s.chainIdHex;
ethState.networkVersion = s.networkVersion;
window.ethereum.selectedAddress = ethState.address;
window.ethereum.chainId = ethState.chainIdHex;
window.ethereum.networkVersion = ethState.networkVersion;
if (s.chainIdHex !== oldChain) ethEmit("chainChanged", s.chainIdHex);
if ((s.address || null) !== oldAddr) ethEmit("accountsChanged", s.address ? [s.address] : []);
} catch {}
}
async function ethHandle(method, params) {
params = Array.isArray(params) ? params : (params ? [params] : []);
switch (method) {
case "eth_requestAccounts": {
const r = await invoke("eth.requestAccounts");
ethState.address = r.address;
ethState.chainIdHex = r.chainIdHex;
ethState.networkVersion = r.networkVersion;
window.ethereum.selectedAddress = r.address;
window.ethereum.chainId = r.chainIdHex;
window.ethereum.networkVersion = r.networkVersion;
ethEmit("accountsChanged", [r.address]);
return [r.address];
}
case "eth_accounts":
return ethState.address ? [ethState.address] : [];
case "eth_chainId":
return ethState.chainIdHex;
case "net_version":
return ethState.networkVersion;
case "personal_sign": {
// Both param orders are seen in the wild: [message, from] and [from, message].
const [a, b] = params;
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
const message = looksLikeAddr(a) ? b : a;
return (await invoke("eth.personalSign", { message: String(message) })).signature;
}
case "eth_sign": {
// Blind signing of an arbitrary 32-byte hash — the same digest can be
// a transaction. MetaMask ships it disabled; Aegis does not offer it.
const err = new Error("eth_sign is disabled in Aegis (blind signing). Use personal_sign or eth_signTypedData_v4.");
err.code = 4200;
throw err;
}
case "eth_sendTransaction": {
const tx = params[0] || {};
return (await invoke("eth.sendTransaction", { tx })).txid;
}
case "eth_signTypedData_v4":
case "eth_signTypedData":
case "eth_signTypedData_v3": {
// v3/v4 differ mostly in nested-struct support; the encoder handles
// both. v1 is the flat "type[]" schema that Metamask deprecated —
// reject it, dapps that still use v1 should upgrade.
const [a, b] = params;
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
const typedData = looksLikeAddr(a) ? b : a;
return (await invoke("eth.signTypedData", { typedData })).signature;
}
case "wallet_switchEthereumChain": {
const target = String((params[0] && params[0].chainId) || "").toLowerCase();
try {
const r = await invoke("eth.switchChain", { chainId: target });
await pullEthStateAndEmit();
return r;
}
catch (e) {
// EIP-3326: preserve the 4902 signal the isolated-world handler
// stamps on the Error so dapps fall through to addChain.
if (/is not added/i.test(e.message || "")) { const err = new Error(e.message); err.code = 4902; throw err; }
throw e;
}
}
case "wallet_addEthereumChain": {
// EIP-3085. Approval overlay + persistence live in the addon.
const r = await invoke("eth.addChain", { params: params[0] });
await pullEthStateAndEmit();
return r;
}
case "wallet_getPermissions":
case "wallet_requestPermissions":
// Minimum shape most dapps accept.
return [{ parentCapability: "eth_accounts" }];
default:
// Read passthrough: eth_getBalance, eth_call, eth_blockNumber, etc.
return invoke("eth.rpc", { method, params });
}
}
const ethereum = {
isAegis: true,
isMetaMask: false,
chainId: ethState.chainIdHex,
networkVersion: ethState.networkVersion,
selectedAddress: null,
request: (opts) => ethHandle(String(opts && opts.method || ""), (opts && opts.params) || []),
// EIP-1193 event surface.
on: (event, fn) => { if (ethListeners[event]) ethListeners[event].push(fn); },
removeListener: (event, fn) => {
const arr = ethListeners[event]; if (!arr) return;
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
},
// Legacy compat some old dapps still call.
enable: () => ethereum.request({ method: "eth_requestAccounts" }),
sendAsync: (payload, cb) => ethereum.request(payload).then((result) => cb(null, { id: payload.id, jsonrpc: "2.0", result }), (err) => cb(err)),
send: (methodOrPayload, params) => ethereum.request(typeof methodOrPayload === "string" ? { method: methodOrPayload, params } : methodOrPayload),
};
// ---- Solana (wallet-adapter shape) --------------------------------------
const solListeners = { connect: [], disconnect: [], accountChanged: [] };
let solState = { publicKey: null };
function makePubkey(base58) {
return {
toString: () => base58,
toBase58: () => base58,
toBytes: () => base58ToBytes(base58),
equals: (other) => other && other.toString && other.toString() === base58,
_bn: null,
};
}
// Local base58 decoder — needed to expose PublicKey.toBytes(). Alphabet
// matches Bitcoin's (the only base58 flavor in real use).
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function base58ToBytes(s) {
let zeros = 0;
while (zeros < s.length && s[zeros] === B58[0]) zeros++;
const out = new Uint8Array(Math.ceil(s.length * 733 / 1000 + 1));
let length = 0;
for (let i = zeros; i < s.length; i++) {
const v = B58.indexOf(s[i]);
if (v < 0) throw new Error("bad base58 char " + s[i]);
let carry = v, j = 0;
for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
carry += 58 * out[k];
out[k] = carry & 0xff;
carry >>>= 8;
}
length = j;
}
let it = out.length - length;
while (it < out.length && out[it] === 0) it++;
const total = zeros + (out.length - it);
const dec = new Uint8Array(total);
let p = zeros;
while (it < out.length) dec[p++] = out[it++];
return dec;
}
function bytesToBase58(b) {
let zeros = 0;
while (zeros < b.length && b[zeros] === 0) zeros++;
const buf = new Uint8Array(Math.ceil(b.length * 138 / 100 + 1));
let length = 0;
for (let i = zeros; i < b.length; i++) {
let carry = b[i], j = 0;
for (let k = buf.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
carry += (buf[k] << 8) >>> 0;
buf[k] = carry % 58;
carry = (carry / 58) | 0;
}
length = j;
}
let it = buf.length - length;
while (it < buf.length && buf[it] === 0) it++;
let out = "";
for (let i = 0; i < zeros; i++) out += B58[0];
for (; it < buf.length; it++) out += B58[buf[it]];
return out;
}
function u8ToBase64(u8) {
let s = "";
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
return btoa(s);
}
function base64ToU8(s) {
const bin = atob(s);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
async function solConnect() {
const r = await invoke("sol.connect");
solState.publicKey = makePubkey(r.address);
window.solana.publicKey = solState.publicKey;
window.solana.isConnected = true;
wsSetAccount(r.address);
for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} }
return { publicKey: solState.publicKey };
}
async function solDisconnect() {
solState.publicKey = null;
window.solana.publicKey = null;
window.solana.isConnected = false;
wsSetAccount(null);
for (const fn of solListeners.disconnect) { try { fn(); } catch {} }
}
// ---- Wallet Standard (how Solana dapps find wallets now) -----------------
// Aegis used to get picked by setting isPhantom: true — claiming to be a
// different wallet, which shows Phantom's name and icon for Aegis and
// breaks any dapp that then relies on Phantom-only behaviour. Registering
// as "Aegis" through the Wallet Standard puts it in every wallet-adapter
// list under its own name instead.
const SOL_CHAINS = ["solana:mainnet", "solana:devnet"];
const SOL_FEATURES = ["solana:signAndSendTransaction", "solana:signTransaction", "solana:signMessage"];
let wsAccounts = [];
const wsChange = [];
function wsSetAccount(base58) {
wsAccounts = base58 ? [Object.freeze({ address: base58, publicKey: base58ToBytes(base58), chains: SOL_CHAINS, features: SOL_FEATURES })] : [];
for (const fn of wsChange.slice()) { try { fn({ accounts: wsAccounts }); } catch {} }
}
// Wire layout: compact-u16(sigCount) || sig[64]… || message. Returns where
// the signatures and the message start.
function splitWire(wire) {
let off = 0, n = 0, shift = 0;
for (;;) { const b = wire[off++]; n |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; if (shift > 14) throw new Error("Aegis: bad transaction"); }
return { sigCount: n, sigsStart: off, messageStart: off + n * 64 };
}
// Put our signature into our slot: the slot index is our key's position
// among the message's signer keys (legacy and v0 alike).
function patchSignature(wire, pubBytes, sig) {
const { sigCount, sigsStart, messageStart } = splitWire(wire);
let m = messageStart;
if (wire[m] & 0x80) m++; // v0 version prefix
m += 3; // header
let keyCount = 0, shift = 0;
for (;;) { const b = wire[m++]; keyCount |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7; }
for (let i = 0; i < Math.min(keyCount, sigCount); i++) {
let eq = true;
for (let j = 0; j < 32; j++) if (wire[m + i * 32 + j] !== pubBytes[j]) { eq = false; break; }
if (eq) { const out = new Uint8Array(wire); out.set(sig, sigsStart + i * 64); return out; }
}
throw new Error("Aegis: this wallet is not a signer of the transaction");
}
const AEGIS_ICON = "data:image/svg+xml;base64," + btoa("<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><polygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='#0a0a0d' stroke='#d6ff3d' stroke-width='2.5'/><circle cx='16' cy='16' r='4.3' fill='none' stroke='#d6ff3d' stroke-width='1.6'/><circle cx='16' cy='16' r='1.6' fill='#d6ff3d'/></svg>");
const standardWallet = {
version: "1.0.0",
name: "Aegis",
icon: AEGIS_ICON,
chains: SOL_CHAINS,
get accounts() { return wsAccounts; },
features: {
"standard:connect": { version: "1.0.0", connect: async (input) => {
if (input && input.silent) {
// Restore a session, never prompt.
if (!wsAccounts.length) {
const s = await invoke("sol.state").catch(() => null);
if (s && s.address) { solState.publicKey = makePubkey(s.address); solana.publicKey = solState.publicKey; solana.isConnected = true; wsSetAccount(s.address); }
}
return { accounts: wsAccounts };
}
await solConnect();
return { accounts: wsAccounts };
} },
"standard:disconnect": { version: "1.0.0", disconnect: async () => { await solDisconnect(); } },
"standard:events": { version: "1.0.0", on: (event, fn) => {
if (event !== "change" || typeof fn !== "function") return () => {};
wsChange.push(fn);
return () => { const i = wsChange.indexOf(fn); if (i >= 0) wsChange.splice(i, 1); };
} },
"solana:signMessage": { version: "1.1.0", signMessage: async (...inputs) => {
const out = [];
for (const inp of inputs) {
const message = inp.message instanceof Uint8Array ? inp.message : new Uint8Array(inp.message || []);
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(message) });
out.push({ signedMessage: message, signature: base58ToBytes(r.signature) });
}
return out;
} },
"solana:signTransaction": { version: "1.0.0", supportedTransactionVersions: ["legacy", 0], signTransaction: async (...inputs) => {
const out = [];
for (const inp of inputs) {
const wire = inp.transaction instanceof Uint8Array ? inp.transaction : new Uint8Array(inp.transaction || []);
const { messageStart } = splitWire(wire);
const r = await invoke("sol.signTransaction", { messageB64: u8ToBase64(wire.subarray(messageStart)) });
out.push({ signedTransaction: patchSignature(wire, base58ToBytes(r.publicKey || r.address), base58ToBytes(r.signature)) });
}
return out;
} },
"solana:signAndSendTransaction": { version: "1.0.0", supportedTransactionVersions: ["legacy", 0], signAndSendTransaction: async (...inputs) => {
const out = [];
for (const inp of inputs) {
const wire = inp.transaction instanceof Uint8Array ? inp.transaction : new Uint8Array(inp.transaction || []);
const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(wire) });
out.push({ signature: base58ToBytes(r.txid) });
}
return out;
} },
},
};
function registerStandardWallet() {
const callback = (api) => { try { api.register(standardWallet); } catch {} };
try { window.dispatchEvent(new CustomEvent("wallet-standard:register-wallet", { detail: callback })); } catch {}
try { window.addEventListener("wallet-standard:app-ready", (e) => callback(e.detail)); } catch {}
}
async function solSignMessage(u8) {
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []);
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) });
return { publicKey: solState.publicKey, signature: base58ToBytes(r.signature) };
}
// Solana Transaction objects have serializeMessage() → Uint8Array and
// addSignature(publicKey, sig) — we live in the main world, so we can
// call both. Wallets that live in an isolated content-script can't.
async function solSignAndSendTransaction(tx, opts) {
if (!tx || typeof tx.serialize !== "function") {
throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serialize)");
}
// Serialize the FULL wire including any partial signatures the dapp
// has already collected (multi-signer flows: session keys, escrows,
// ephemeral co-signers). Aegis fills the wallet's own signature slot
// in the addon and leaves the other slots untouched.
const wire = tx.serialize({ requireAllSignatures: false, verifySignatures: false });
const r = await invoke("sol.signAndSend", { wireB64: u8ToBase64(new Uint8Array(wire)) });
return { signature: r.txid, publicKey: solState.publicKey };
}
async function solSignTransaction(tx) {
// Legacy Transaction has serializeMessage(); VersionedTransaction keeps
// its message under .message. Both take addSignature(publicKey, sig).
const legacy = tx && typeof tx.serializeMessage === "function";
if (!tx || typeof tx.addSignature !== "function" || (!legacy && !(tx.message && typeof tx.message.serialize === "function"))) {
throw new Error("Aegis: pass a @solana/web3.js Transaction or VersionedTransaction");
}
const messageBytes = legacy ? tx.serializeMessage() : tx.message.serialize();
// A transaction is signed through its own handler + overlay; the
// "sign a message" path refuses transaction-shaped bytes on purpose.
const r = await invoke("sol.signTransaction", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) });
// r.signature is base58 of the 64-byte ed25519 sig.
tx.addSignature(solState.publicKey, base58ToBytes(r.signature));
return tx;
}
const solana = {
isAegis: true,
// No isPhantom: Aegis announces itself as Aegis (Wallet Standard, below).
isConnected: false,
publicKey: null,
connect: async (opts) => solConnect(opts),
disconnect: solDisconnect,
signMessage: (u8) => solSignMessage(u8),
signTransaction: (tx) => solSignTransaction(tx),
signAllTransactions: async (txs) => {
const out = [];
for (const tx of txs) out.push(await solSignTransaction(tx));
return out;
},
signAndSendTransaction: (tx, opts) => solSignAndSendTransaction(tx, opts),
request: async (opts) => {
const method = String(opts && opts.method || "");
const params = opts && opts.params || {};
if (method === "connect") return solConnect();
if (method === "disconnect") return solDisconnect();
if (method === "signMessage") return solSignMessage(params.message);
if (method === "signTransaction") return solSignTransaction(params.transaction);
if (method === "signAndSendTransaction") return solSignAndSendTransaction(params.transaction, params.options);
throw new Error("Aegis: unsupported solana method " + method);
},
on: (event, fn) => { if (solListeners[event]) solListeners[event].push(fn); },
off: (event, fn) => {
const arr = solListeners[event]; if (!arr) return;
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
},
removeAllListeners: () => { Object.keys(solListeners).forEach((k) => solListeners[k].length = 0); },
};
function dispatchEvent(event, data) {
if (event === "eth.accountsChanged") { ethState.address = data.address || null; ethereum.selectedAddress = ethState.address; ethEmit("accountsChanged", data.address ? [data.address] : []); }
else if (event === "eth.chainChanged") { ethState.chainIdHex = data.chainIdHex; ethState.networkVersion = data.networkVersion; ethereum.chainId = data.chainIdHex; ethereum.networkVersion = data.networkVersion; ethEmit("chainChanged", data.chainIdHex); }
else if (event === "sol.accountChanged") {
const pk = data.address ? makePubkey(data.address) : null;
solState.publicKey = pk; solana.publicKey = pk;
for (const fn of solListeners.accountChanged || []) { try { fn(pk); } catch {} }
}
}
// Install the globals. Defined lazily via Object.defineProperty so we
// survive dapps that check hasOwnProperty(window, "ethereum") after page
// load — MetaMask does the same trick.
try { Object.defineProperty(window, "ethereum", { value: ethereum, writable: false, configurable: false }); } catch { window.ethereum = ethereum; }
try { Object.defineProperty(window, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; }
registerStandardWallet();
// EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis.
try {
const info = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
const announce = () => window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider: ethereum }) }));
announce();
window.addEventListener("eip6963:requestProvider", announce);
} catch {}
// Same announce/request handshake for WizardConnect. The WC SDK defines
// no discovery mechanism at all, so we borrow EIP-6963's shape: a dapp
// that wants to support several WC wallets dispatches
// "wizardconnect:requestProvider" and collects the announcements instead
// of reaching for window.wizardconnect and finding whoever won the race.
// window.wizardconnect stays for the simple single-wallet case.
try {
const wcInfo = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
const announceWc = () => {
const provider = window.wizardconnect;
if (!provider) return;
window.dispatchEvent(new CustomEvent("wizardconnect:announceProvider", { detail: Object.freeze({ info: wcInfo, provider }) }));
};
announceWc();
window.addEventListener("wizardconnect:requestProvider", announceWc);
} catch {}
})();`;
// Actually push the script into the main world. Doing this at
// document_start (which is when this preload runs) means the bridge is in
// place before the dapp's own scripts execute.
//
// 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;
//
// The memory used to be "aegis:bridge-blocked", set on ANY failure. The
// commonest failure was ours: this preload often runs before the page has an
// <html> element, so the append threw on null and the origin lost the bridge
// for good. That key is ignored now; only a Trusted Types refusal is kept.
const BLOCKED_KEY = "aegis:bridge-blocked-tt";
function bridgeAllowedHere() {
try {
if (NO_BRIDGE_HOSTS.test(location.hostname)) return false;
if (localStorage.getItem(BLOCKED_KEY) === "1") return false;
} catch {}
return true;
}
function installBridge() {
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 = src;
(document.head || document.documentElement).appendChild(s);
s.remove();
} catch (e) {
const msg = String(e && e.message || e);
if (/trusted/i.test(msg)) {
try { localStorage.setItem(BLOCKED_KEY, "1"); } catch {}
console.warn("[aegis] this site enforces Trusted Types; the wallet bridge stays off here:", msg);
} else {
console.warn("[aegis] main-world bridge install failed:", msg);
}
}
}
if (bridgeAllowedHere()) {
if (document.documentElement) installBridge();
else {
// The parser inserts <html> before it runs any page script, and
// observer callbacks run at the microtask checkpoint that precedes each
// parser-inserted script, so the bridge is still first.
const mo = new MutationObserver(() => {
if (!document.documentElement) return;
mo.disconnect();
installBridge();
});
mo.observe(document, { childList: true });
}
}