feat(theseus/aegis): EIP-1193 + Solana wallet-adapter bridges; BTC signet

Aegis now integrates with the two dapp-wallet APIs the wider ecosystem
actually uses — MetaMask-style window.ethereum for Ethereum, Phantom-style
window.solana for Solana — plus BTC signet as a third Bitcoin network
alongside mainnet + testnet3.

- wallet-inject.js: adds a main-world bridge, installed via a one-shot
  <script textContent=…> appended to <head> and immediately removed.
  Electron's contextBridge shallow-copies args and strips methods, which
  means BCH- and Tron-shaped params (plain data) work in the isolated
  world but Solana's wallet-adapter dapps — which pass @solana/web3.js
  Transaction objects and expect .serializeMessage()/.addSignature() to
  fire on them — need code that lives in the same world as the dapp.
  Bridge talks back to the isolated world via window.postMessage on a
  namespaced envelope (aegisTag = "aegis-" + addonId), which forwards to
  theseus.invoke. Same pattern MetaMask + Phantom use.
- window.ethereum (EIP-1193): request({method, params}), on(),
  removeListener(), chainId, networkVersion, selectedAddress. Handles
  eth_requestAccounts, eth_accounts, eth_chainId, net_version,
  personal_sign, eth_sign, eth_sendTransaction, wallet_switchEthereumChain
  (rejects with "use the Aegis picker"), wallet_addEthereumChain
  (rejects, chains come from Settings), wallet_get/requestPermissions.
  Every other eth_*/net_*/web3_* method passes through to the wallet's
  configured RPC via a new eth.rpc handler. EIP-6963 announceProvider
  event fires so wagmi / RainbowKit / any 6963-aware dapp discovers
  Aegis alongside MetaMask instead of racing for window.ethereum.
- window.solana (wallet-adapter shape): connect(), disconnect(),
  publicKey (with toString/toBase58/toBytes/equals — the PublicKey
  interface dapps check), signMessage(u8) → {publicKey, signature: u8},
  signTransaction(tx) → mutates + returns the same tx with the
  signature added, signAndSendTransaction(tx) → returns {signature: txid},
  signAllTransactions([tx]), request({method, params}). isPhantom flag
  set true so dapps that gate on it pick us. on/off events for connect
  / disconnect / accountChanged.
- Handlers in index.js registerPageMessages: eth.requestAccounts,
  eth.personalSign, eth.sendTransaction, eth.switchChain, eth.rpc,
  sol.connect, sol.signMessage, sol.signAndSend. Every write path is
  per-origin gated + goes through api.approvalModal with the wallet
  label + network in the row list so the user always knows which
  Aegis wallet is about to sign.
- Signet added to chain-btc.js — signet shares testnet3's address
  format and SLIP-44 coin type (BIP-325 only changed consensus/signing),
  so bitcoinjs-lib.networks.testnet handles derivation unchanged. Only
  the electrum pool (aranguren + wakiyamap) + explorer (mempool.space
  /signet) + faucet (signetfaucet.com) differ. Registered as
  btc:signet in COINS with per-network coinType lookup.

Known limits (follow-ups in the same shape as existing chains):
- SOL signAndSendTransaction is single-signer only; dapps that combine
  the wallet's sig with co-signer sigs need the wire assembled on the
  dapp side.
- ETH eth_signTypedData_v4 (EIP-712) is not wired — the handler set
  covers personal_sign only.
This commit is contained in:
Local Dev 2026-09-07 22:08:56 +02:00
parent 48cb497f59
commit 5880ba3507
3 changed files with 571 additions and 5 deletions

View file

@ -243,15 +243,16 @@ const COINS = {
supportsMessageSign: true,
supportsPageInject: false,
// BIP44/49/84/86 across bc1q… / bc1p… / 3… / 1… on mainnet and
// tb1q… / tb1p… / 2… / m/n… on testnet3. Coin type shifts per
// network (0 for mainnet, 1 for testnet — the BIP44 convention).
// tb1q… / tb1p… / 2… / m/n… on testnet3 + signet. Coin type shifts
// per network (0 for mainnet, 1 for both testnet3 and signet — SLIP-44
// treats every Bitcoin testnet as coin type 1).
addressFamilies: [
{ id: "bip84", purpose: 84, label: "Native SegWit (bc1q… / tb1q…)" },
{ id: "bip86", purpose: 86, label: "Taproot (bc1p… / tb1p…)" },
{ id: "bip49", purpose: 49, label: "Wrapped SegWit (3… / 2…)" },
{ id: "bip44", purpose: 44, label: "Legacy P2PKH (1… / m…, n…)" },
],
coinType: { mainnet: 0, testnet: 1 },
coinType: { mainnet: 0, testnet: 1, signet: 1 },
defaultPurpose: 84,
networks: {
mainnet: {
@ -262,6 +263,10 @@ const COINS = {
id: "testnet", label: "Testnet3", testnet: true,
purposePrefix: "bchwallet/btc/testnet/", startIndex: 0,
},
signet: {
id: "signet", label: "Signet", testnet: true,
purposePrefix: "bchwallet/btc/signet/", startIndex: 0,
},
},
},
};
@ -1091,6 +1096,229 @@ function registerPageMessages(api) {
return rt.adapter.signMessageV2(message);
});
});
// ---- Ethereum (EIP-1193) ---------------------------------------------
// Routes the same way as Tron: pick the currently-selected ETH wallet if
// any; else the first ready ETH wallet. Chain switches happen at the
// wallet-picker level, not here — dapps that call wallet_switchEthereumChain
// get a friendly "switch wallet in the Aegis sidebar" error.
function activeEthRuntime() {
const selId = selectedWalletId();
const selRt = selId && ctx.runtimes.get(selId);
if (selRt && selRt.entry.chain === "eth" && selRt.phase === "ready") return selRt;
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "eth" && rt.phase === "ready") return rt;
throw new Error("no Ethereum wallet available — add one in the Aegis sidebar");
}
function ethConnectedFor(origin) {
const p = permissions(api)[origin];
return !!(p && p.eth && p.eth.readAddress);
}
api.onMessage("eth.requestAccounts", async (_p, m) => {
const origin = fromPage(m);
const rt = activeEthRuntime();
const snap = rt.adapter.snapshot();
const chainIdHex = "0x" + Number(snap.chainId).toString(16);
const networkVersion = String(snap.chainId);
const perms = permissions(api);
if (perms[origin] && perms[origin].eth && perms[origin].eth.readAddress) {
return { address: snap.address, chainIdHex, networkVersion };
}
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Connect this site to your Ethereum wallet?",
origin,
body: "The site will see this address and can build transactions for you to sign.",
rows: [
{ label: "Address", value: snap.address, mono: true },
{ label: "Network", value: snap.network === "mainnet" ? "Ethereum mainnet" : "Sepolia testnet" },
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
],
actions: [{ id: "allow", label: "Connect", primary: true }],
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
perms[origin] = { ...(perms[origin] || {}), eth: { readAddress: true, chainId: snap.chainId } };
api.storage.set("permissions", perms);
emitState();
}
return { address: snap.address, chainIdHex, networkVersion };
});
});
api.onMessage("eth.personalSign", async (p, m) => {
const origin = fromPage(m);
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
const rt = activeEthRuntime();
const message = String(p && p.message != null ? p.message : "");
if (message.length > 4096) throw new Error("message too long");
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Sign an Ethereum message?",
origin,
body: "Signing proves you control this address. It moves no ETH.",
rows: [
{ label: "Message", value: message.length > 400 ? message.slice(0, 400) + "…" : message, mono: true },
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
return rt.adapter.signMessage(message);
});
});
api.onMessage("eth.sendTransaction", async (p, m) => {
const origin = fromPage(m);
if (!ethConnectedFor(origin)) throw new Error("not connected — call eth_requestAccounts first");
const rt = activeEthRuntime();
const tx = (p && p.tx) || {};
if (!tx.to) throw new Error("tx.to required");
// MetaMask semantics: `value` and `gas`/`gasLimit` are hex-encoded wei;
// convert to numbers/bigints Aegis's own plan() understands.
const valueWei = tx.value ? BigInt(tx.value).toString() : "0";
return withOriginLock(origin, async () => {
const snap = rt.adapter.snapshot();
const plan = await rt.adapter.plan({ to: tx.to, amount: valueWei, sendMax: false });
const meta = chainMeta("eth", rt.entry.network);
const pick = await api.approvalModal({
title: "Send Ethereum transaction?",
origin,
body: tx.data && tx.data !== "0x" ? "This transaction carries call data (a contract call). Check the destination + value carefully." : "This site is asking your wallet to send ETH.",
rows: [
{ label: "To", value: plan.recipients[0].to, mono: true },
{ label: "Amount", value: `${fmtValue(plan.recipients[0].value, meta.decimals)} ETH`, strong: true },
{ label: "Fee (est.)", value: `${fmtValue(plan.fee, meta.decimals)} ETH` },
{ label: "Wallet", value: `${rt.entry.label} — Ethereum · ${snap.network}` },
],
actions: [{ id: "send", label: "Send", primary: true }],
});
if (pick !== "send") throw new Error("user rejected");
const r = await rt.adapter.signAndBroadcast(plan);
return { txid: r.txid };
});
});
api.onMessage("eth.switchChain", async (p, m) => {
fromPage(m);
const want = String(p && p.chainId || "").toLowerCase();
const rt = activeEthRuntime();
const cur = "0x" + Number(rt.adapter.snapshot().chainId).toString(16);
if (want === cur) return null;
throw new Error(`Aegis: switch chains via the Aegis sidebar picker. This wallet is on ${cur}; site asked for ${want}.`);
});
// Read passthrough: forward eth_getBalance / eth_call / etc. to the
// wallet's own configured RPC. Nothing here reveals the private key.
api.onMessage("eth.rpc", async (p, m) => {
fromPage(m);
const rt = activeEthRuntime();
const method = String(p && p.method || "");
const params = (p && p.params) || [];
if (!/^eth_|^net_|^web3_/.test(method)) throw new Error("Aegis: only eth_/net_/web3_ read methods are passed through");
return rt.adapter._client.call(method, params);
});
// ---- Solana (wallet-adapter) ------------------------------------------
function activeSolRuntime() {
const selId = selectedWalletId();
const selRt = selId && ctx.runtimes.get(selId);
if (selRt && selRt.entry.chain === "sol" && selRt.phase === "ready") return selRt;
for (const rt of ctx.runtimes.values()) if (rt.entry.chain === "sol" && rt.phase === "ready") return rt;
throw new Error("no Solana wallet available — add one in the Aegis sidebar");
}
function solConnectedFor(origin) {
const p = permissions(api)[origin];
return !!(p && p.sol && p.sol.readAddress);
}
api.onMessage("sol.connect", async (_p, m) => {
const origin = fromPage(m);
const rt = activeSolRuntime();
const snap = rt.adapter.snapshot();
if (solConnectedFor(origin)) return { address: snap.address, network: snap.network };
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Connect this site to your Solana wallet?",
origin,
body: "The site will see this address and can build transactions for you to sign.",
rows: [
{ label: "Address", value: snap.address, mono: true },
{ label: "Network", value: snap.network === "mainnet" ? "Mainnet-beta" : "Devnet" },
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
],
actions: [{ id: "allow", label: "Connect", primary: true }],
checkbox: { id: "always", label: "Always allow this site to see this address" },
});
if (!pick.startsWith("allow")) throw new Error("user rejected");
if (pick === "allow+always") {
const perms = permissions(api);
perms[origin] = { ...(perms[origin] || {}), sol: { readAddress: true, network: snap.network } };
api.storage.set("permissions", perms);
emitState();
}
return { address: snap.address, network: snap.network };
});
});
api.onMessage("sol.signMessage", async (p, m) => {
const origin = fromPage(m);
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
const rt = activeSolRuntime();
const b64 = String(p && p.messageB64 || "");
const bytes = Buffer.from(b64, "base64");
if (bytes.length > 4096) throw new Error("message too long");
return withOriginLock(origin, async () => {
const preview = bytes.every((c) => c >= 0x20 && c < 0x7f) ? bytes.toString("utf8") : `<${bytes.length} bytes: 0x${bytes.toString("hex").slice(0, 60)}…>`;
const pick = await api.approvalModal({
title: "Sign a Solana message?",
origin,
body: "Signing proves you control this address. It moves no SOL.",
rows: [
{ label: "Message", value: preview.length > 400 ? preview.slice(0, 400) + "…" : preview, mono: true },
{ label: "Address", value: rt.adapter.snapshot().address, mono: true },
],
actions: [{ id: "sign", label: "Sign", primary: true }],
});
if (pick !== "sign") throw new Error("user rejected");
return rt.adapter.signMessage(bytes);
});
});
// Dapp-built transaction: page hands us the wallet-adapter Transaction's
// .serializeMessage() output (base64). We sign it + broadcast via the
// adapter's own RPC. The txid returned by broadcast is the wire's txid.
api.onMessage("sol.signAndSend", async (p, m) => {
const origin = fromPage(m);
if (!solConnectedFor(origin)) throw new Error("not connected — call solana.connect first");
const rt = activeSolRuntime();
const b64 = String(p && p.messageB64 || "");
const messageBytes = new Uint8Array(Buffer.from(b64, "base64"));
return withOriginLock(origin, async () => {
const snap = rt.adapter.snapshot();
const pick = await api.approvalModal({
title: "Sign + send a Solana transaction?",
origin,
body: "The site built this transaction. Aegis can't decode arbitrary Solana instructions in this rev — verify the site before signing.",
rows: [
{ label: "Message size", value: `${messageBytes.length} bytes` },
{ label: "Address", value: snap.address, mono: true },
{ label: "Wallet", value: `${rt.entry.label} — Solana · ${snap.network}` },
],
actions: [{ id: "send", label: "Sign & send", primary: true }],
});
if (pick !== "send") throw new Error("user rejected");
// Sign the message with the wallet's ed25519 key (Solana signs the
// raw message bytes, no prefix).
const sigInfo = rt.adapter.signMessage(messageBytes); // {signature: base58}
const sigBytes = ctx.d.base58check.decodeBase58(sigInfo.signature);
if (sigBytes.length !== 64) throw new Error("bad ed25519 signature length");
// Wire = compact-u16(1) || 64-byte sig || message. Single-signer only
// in this rev: multi-signer flows would need the dapp's other sigs.
const wire = new Uint8Array(1 + 64 + messageBytes.length);
wire[0] = 0x01;
wire.set(sigBytes, 1);
wire.set(messageBytes, 65);
const wireB58 = ctx.d.base58check.encodeBase58(wire);
const txid = await rt.adapter._client.call("sendTransaction", [wireB58]);
if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid));
setTimeout(() => rt.adapter.refresh().catch(() => {}), 4000);
return { txid };
});
});
}
// ---- activate ---------------------------------------------------------------

View file

@ -36,6 +36,24 @@ const NETWORKS = {
],
faucet: "https://coinfaucet.eu/en/btc-testnet/",
},
signet: {
// Signet (BIP-325) shares testnet's address format and SLIP-44 coin
// type (1), so bitcoinjs-lib's `networks.testnet` handles address
// derivation unchanged. The chain itself is a separate, permissioned
// testnet with its own genesis + signer-signed blocks; from a wallet's
// point of view, the only differences are the electrum pool serving
// it and the explorer URL for tx lookups.
id: "signet", label: "Signet",
hrp: "tb", coinType: 1,
defaultAccountPath: "m/84'/1'/0'",
explorerTx: "https://mempool.space/signet/tx/",
explorerAddr: "https://mempool.space/signet/address/",
defaultServers: [
"wss://signet.aranguren.org:51102",
"wss://signet-electrumx.wakiyamap.dev:50003",
],
faucet: "https://signetfaucet.com/",
},
};
module.exports = function makeBtcAdapter({
@ -52,10 +70,12 @@ module.exports = function makeBtcAdapter({
// ECDSA and schnorr, so initEccLib once at load makes p2tr resolve.
try { bitcoinjs.initEccLib && bitcoinjs.initEccLib(ecc); } catch {}
// Map our network id → bitcoinjs-lib Network object.
// Map our network id → bitcoinjs-lib Network object. Signet shares
// testnet's address prefixes + magic (BIP-325 defines only new consensus
// rules; the p2p / address layer stays testnet-compatible).
function bjsNetworkFor(id) {
if (id === "mainnet") return bjsNetworks.bitcoin;
if (id === "testnet") return bjsNetworks.testnet;
if (id === "testnet" || id === "signet") return bjsNetworks.testnet;
throw new Error("chain-btc: unknown network " + id);
}

View file

@ -140,3 +140,321 @@ const tronLink = {
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
//
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
// object to touch the dapp's own JS values — Solana in particular passes
// `Transaction` instances whose `.serializeMessage()` method the wallet has
// to call. Electron's contextBridge shallow-copies function arguments
// between worlds and strips methods, so bridges that need to call methods
// on dapp-side objects have to live in the main world. We inject a `<script>`
// with the bridge source; it runs synchronously in the main world and talks
// to us via `window.postMessage` on a namespaced envelope. This mirrors how
// MetaMask and Phantom bridge extension code back to page code.
// Namespace used on the postMessage envelope. Includes the addon id so a
// page that runs multiple dapp-wallet extensions doesn't misroute messages.
const AEGIS_TAG = "aegis-" + theseus.id;
const pendingCalls = new Map();
window.addEventListener("message", async (e) => {
const d = e && e.data;
if (!d || d.aegisTag !== AEGIS_TAG) return;
if (d.kind === "request") {
// Forward main-world → isolated-world → addon.
try {
const result = await call(d.msg, d.payload);
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: true, result }, location.origin);
} catch (err) {
window.postMessage({ aegisTag: AEGIS_TAG, kind: "response", id: d.id, ok: false, error: String(err && err.message || err) }, location.origin);
}
}
});
function emitToMainWorld(event, data) {
try { window.postMessage({ aegisTag: AEGIS_TAG, kind: "event", event, data }, location.origin); } catch {}
}
// The main-world bridge — installed as a page-level `<script>` so it can
// call methods on Transaction objects the dapp hands it, and so the globals
// it defines look like ordinary page code to the dapp.
const mainWorldSource = `(function () {
if (window.__aegisBridge) return;
window.__aegisBridge = true;
const TAG = ${JSON.stringify(AEGIS_TAG)};
const pending = new Map();
let seq = 1;
function invoke(msg, payload) {
const id = "r" + (seq++);
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.postMessage({ aegisTag: TAG, kind: "request", id, msg, payload }, location.origin);
});
}
window.addEventListener("message", (e) => {
const d = e && e.data;
if (!d || d.aegisTag !== TAG) return;
if (d.kind === "response") {
const p = pending.get(d.id);
if (!p) return;
pending.delete(d.id);
d.ok ? p.resolve(d.result) : p.reject(new Error(d.error));
} else if (d.kind === "event") {
dispatchEvent(d.event, d.data);
}
});
// ---- Ethereum (EIP-1193) ------------------------------------------------
const ethListeners = { connect: [], disconnect: [], accountsChanged: [], chainChanged: [], message: [] };
let ethState = { address: null, chainIdHex: "0x1", networkVersion: "1" };
function ethEmit(event, data) {
for (const fn of ethListeners[event] || []) { try { fn(data); } catch {} }
}
async function ethHandle(method, params) {
params = Array.isArray(params) ? params : (params ? [params] : []);
switch (method) {
case "eth_requestAccounts": {
const r = await invoke("eth.requestAccounts");
ethState.address = r.address;
ethState.chainIdHex = r.chainIdHex;
ethState.networkVersion = r.networkVersion;
window.ethereum.selectedAddress = r.address;
window.ethereum.chainId = r.chainIdHex;
window.ethereum.networkVersion = r.networkVersion;
ethEmit("accountsChanged", [r.address]);
return [r.address];
}
case "eth_accounts":
return ethState.address ? [ethState.address] : [];
case "eth_chainId":
return ethState.chainIdHex;
case "net_version":
return ethState.networkVersion;
case "personal_sign": {
// Both param orders are seen in the wild: [message, from] and [from, message].
const [a, b] = params;
const looksLikeAddr = (s) => typeof s === "string" && /^0x[0-9a-fA-F]{40}$/.test(s);
const message = looksLikeAddr(a) ? b : a;
return (await invoke("eth.personalSign", { message: String(message) })).signature;
}
case "eth_sign": {
// Legacy method. Same shape as personal_sign for our purposes.
const [_from, msg] = params;
return (await invoke("eth.personalSign", { message: String(msg) })).signature;
}
case "eth_sendTransaction": {
const tx = params[0] || {};
return (await invoke("eth.sendTransaction", { tx })).txid;
}
case "wallet_switchEthereumChain": {
const target = String((params[0] && params[0].chainId) || "").toLowerCase();
return invoke("eth.switchChain", { chainId: target });
}
case "wallet_addEthereumChain":
throw new Error("Aegis: chains are managed from the wallet's own settings; add the chain there.");
case "wallet_getPermissions":
case "wallet_requestPermissions":
// Minimum shape most dapps accept.
return [{ parentCapability: "eth_accounts" }];
default:
// Read passthrough: eth_getBalance, eth_call, eth_blockNumber, etc.
return invoke("eth.rpc", { method, params });
}
}
const ethereum = {
isAegis: true,
isMetaMask: false,
chainId: ethState.chainIdHex,
networkVersion: ethState.networkVersion,
selectedAddress: null,
request: (opts) => ethHandle(String(opts && opts.method || ""), (opts && opts.params) || []),
// EIP-1193 event surface.
on: (event, fn) => { if (ethListeners[event]) ethListeners[event].push(fn); },
removeListener: (event, fn) => {
const arr = ethListeners[event]; if (!arr) return;
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
},
// Legacy compat some old dapps still call.
enable: () => ethereum.request({ method: "eth_requestAccounts" }),
sendAsync: (payload, cb) => ethereum.request(payload).then((result) => cb(null, { id: payload.id, jsonrpc: "2.0", result }), (err) => cb(err)),
send: (methodOrPayload, params) => ethereum.request(typeof methodOrPayload === "string" ? { method: methodOrPayload, params } : methodOrPayload),
};
// ---- Solana (wallet-adapter shape) --------------------------------------
const solListeners = { connect: [], disconnect: [], accountChanged: [] };
let solState = { publicKey: null };
function makePubkey(base58) {
return {
toString: () => base58,
toBase58: () => base58,
toBytes: () => base58ToBytes(base58),
equals: (other) => other && other.toString && other.toString() === base58,
_bn: null,
};
}
// Local base58 decoder — needed to expose PublicKey.toBytes(). Alphabet
// matches Bitcoin's (the only base58 flavor in real use).
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function base58ToBytes(s) {
let zeros = 0;
while (zeros < s.length && s[zeros] === B58[0]) zeros++;
const out = new Uint8Array(Math.ceil(s.length * 733 / 1000 + 1));
let length = 0;
for (let i = zeros; i < s.length; i++) {
const v = B58.indexOf(s[i]);
if (v < 0) throw new Error("bad base58 char " + s[i]);
let carry = v, j = 0;
for (let k = out.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
carry += 58 * out[k];
out[k] = carry & 0xff;
carry >>>= 8;
}
length = j;
}
let it = out.length - length;
while (it < out.length && out[it] === 0) it++;
const total = zeros + (out.length - it);
const dec = new Uint8Array(total);
let p = zeros;
while (it < out.length) dec[p++] = out[it++];
return dec;
}
function bytesToBase58(b) {
let zeros = 0;
while (zeros < b.length && b[zeros] === 0) zeros++;
const buf = new Uint8Array(Math.ceil(b.length * 138 / 100 + 1));
let length = 0;
for (let i = zeros; i < b.length; i++) {
let carry = b[i], j = 0;
for (let k = buf.length - 1; (carry !== 0 || j < length) && k >= 0; k--, j++) {
carry += (buf[k] << 8) >>> 0;
buf[k] = carry % 58;
carry = (carry / 58) | 0;
}
length = j;
}
let it = buf.length - length;
while (it < buf.length && buf[it] === 0) it++;
let out = "";
for (let i = 0; i < zeros; i++) out += B58[0];
for (; it < buf.length; it++) out += B58[buf[it]];
return out;
}
function u8ToBase64(u8) {
let s = "";
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
return btoa(s);
}
function base64ToU8(s) {
const bin = atob(s);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return u8;
}
async function solConnect() {
const r = await invoke("sol.connect");
solState.publicKey = makePubkey(r.address);
window.solana.publicKey = solState.publicKey;
window.solana.isConnected = true;
for (const fn of solListeners.connect) { try { fn(solState.publicKey); } catch {} }
return { publicKey: solState.publicKey };
}
async function solDisconnect() {
solState.publicKey = null;
window.solana.publicKey = null;
window.solana.isConnected = false;
for (const fn of solListeners.disconnect) { try { fn(); } catch {} }
}
async function solSignMessage(u8) {
if (!(u8 instanceof Uint8Array)) u8 = new Uint8Array(u8 || []);
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(u8) });
return { publicKey: solState.publicKey, signature: base58ToBytes(r.signature) };
}
// Solana Transaction objects have serializeMessage() → Uint8Array and
// addSignature(publicKey, sig) — we live in the main world, so we can
// call both. Wallets that live in an isolated content-script can't.
async function solSignAndSendTransaction(tx, opts) {
if (!tx || typeof tx.serializeMessage !== "function") {
throw new Error("Aegis: pass a @solana/web3.js Transaction (needs .serializeMessage / .addSignature)");
}
const messageBytes = tx.serializeMessage();
const r = await invoke("sol.signAndSend", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) });
return { signature: r.txid, publicKey: solState.publicKey };
}
async function solSignTransaction(tx) {
if (!tx || typeof tx.serializeMessage !== "function" || typeof tx.addSignature !== "function") {
throw new Error("Aegis: pass a @solana/web3.js Transaction");
}
const messageBytes = tx.serializeMessage();
const r = await invoke("sol.signMessage", { messageB64: u8ToBase64(new Uint8Array(messageBytes)) });
// r.signature is base58 of the 64-byte ed25519 sig.
tx.addSignature(solState.publicKey, base58ToBytes(r.signature));
return tx;
}
const solana = {
isAegis: true,
isPhantom: true, // set so dapps that gate on isPhantom pick us
isConnected: false,
publicKey: null,
connect: async (opts) => solConnect(opts),
disconnect: solDisconnect,
signMessage: (u8) => solSignMessage(u8),
signTransaction: (tx) => solSignTransaction(tx),
signAllTransactions: async (txs) => {
const out = [];
for (const tx of txs) out.push(await solSignTransaction(tx));
return out;
},
signAndSendTransaction: (tx, opts) => solSignAndSendTransaction(tx, opts),
request: async (opts) => {
const method = String(opts && opts.method || "");
const params = opts && opts.params || {};
if (method === "connect") return solConnect();
if (method === "disconnect") return solDisconnect();
if (method === "signMessage") return solSignMessage(params.message);
if (method === "signTransaction") return solSignTransaction(params.transaction);
if (method === "signAndSendTransaction") return solSignAndSendTransaction(params.transaction, params.options);
throw new Error("Aegis: unsupported solana method " + method);
},
on: (event, fn) => { if (solListeners[event]) solListeners[event].push(fn); },
off: (event, fn) => {
const arr = solListeners[event]; if (!arr) return;
const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1);
},
removeAllListeners: () => { Object.keys(solListeners).forEach((k) => solListeners[k].length = 0); },
};
function dispatchEvent(event, data) {
if (event === "eth.accountsChanged") { ethState.address = data.address || null; ethereum.selectedAddress = ethState.address; ethEmit("accountsChanged", data.address ? [data.address] : []); }
else if (event === "eth.chainChanged") { ethState.chainIdHex = data.chainIdHex; ethState.networkVersion = data.networkVersion; ethereum.chainId = data.chainIdHex; ethereum.networkVersion = data.networkVersion; ethEmit("chainChanged", data.chainIdHex); }
else if (event === "sol.accountChanged") {
const pk = data.address ? makePubkey(data.address) : null;
solState.publicKey = pk; solana.publicKey = pk;
for (const fn of solListeners.accountChanged || []) { try { fn(pk); } catch {} }
}
}
// Install the globals. Defined lazily via Object.defineProperty so we
// survive dapps that check hasOwnProperty(window, "ethereum") after page
// load — MetaMask does the same trick.
try { Object.defineProperty(window, "ethereum", { value: ethereum, writable: false, configurable: false }); } catch { window.ethereum = ethereum; }
try { Object.defineProperty(window, "solana", { value: solana, writable: false, configurable: false }); } catch { window.solana = solana; }
// EIP-6963 provider announcement so wagmi / RainbowKit discover Aegis.
try {
const info = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
const announce = () => window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider: ethereum }) }));
announce();
window.addEventListener("eip6963:requestProvider", announce);
} catch {}
})();`;
// Actually push the script into the main world. Doing this at
// document_start (which is when this preload runs) means the bridge is in
// place before the dapp's own scripts execute.
try {
const s = document.createElement("script");
s.textContent = mainWorldSource;
(document.head || document.documentElement).appendChild(s);
s.remove();
} catch (e) {
console.warn("[aegis] main-world bridge install failed:", e && e.message || e);
}