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:
parent
48cb497f59
commit
5880ba3507
3 changed files with 571 additions and 5 deletions
|
|
@ -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 ---------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue