From cc980752dc5387702165667ac2648c7bacad99ea Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 7 Sep 2026 23:55:09 +0200 Subject: [PATCH] feat(theseus/aegis): SPL token support (view balances + send) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPL tokens now show up in the Solana wallet — balances on the Receive card, an asset picker on Send that flips the amount input into the token's own units. Sends build a TransferChecked + auto-create the recipient's Associated Token Account (idempotently) in the same transaction, so the user never has to fund an ATA by hand. - lib/sol-spl.js: SPL primitives that don't need @solana/web3.js. TOKEN_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, findProgramAddress (PDA loop backed by an ed25519 is-on-curve check via @noble Point.fromBytes), associatedTokenAddress (matches the spl-token JS seed layout: [owner, tokenProgram, mint]), transferCheckedInstruction (discriminator 12, u64 amount, decimals byte), createATAIdempotentInstruction (associated-token program discriminator 1). A small known-mint registry ships inline for USDC / USDT / wSOL on mainnet + USDC on devnet — everything else falls back to a truncated mint address in the UI. - Message assembler classifies every unique pubkey into writable-signed / readonly-signed / writable-unsigned / readonly-unsigned, sorts the fee payer first, and serializes header + accountKeys + blockhash + instructions using Solana's compact-u16 short-vec encoding. Same wire shape @solana/web3.js produces from Transaction.serializeMessage. - lib/chain-sol.js: snapshot() now carries a tokens[] array of {mint, symbol, name, decimals, balance, tokenAccount, tokenProgram, isKnown, isToken2022}. Fetched via getTokenAccountsByOwner against both the classic Token program and Token-2022. New planTokenTransfer + signAndBroadcastToken handle a full send (TransferChecked + optional CreateATAIdempotent) in one wire. - Panel: Send tab gained an Asset dropdown (SOL / ) that only shows for SOL wallets with tokens. Picking a token flips the unit picker's big-unit to the token symbol, amount goes in the token's own decimals, planTokenSend + sendToken take over from planSend/send. Receive tab gained a Tokens card listing each SPL balance with a per-row Send button that pre-fills the asset picker. - Verified in scratchpad: ATA derivation runs the PDA loop correctly (owner pubkey passes isOnCurve, derived ATA does not — the definitional property of a Program-Derived Address). Cross-check the ATA for any (owner, mint) on Phantom / Solscan / spl-token JS and the value matches. Known limits: - No token metadata lookup on-chain — mints outside the built-in registry show up with a truncated mint address as symbol. Wiring Metaplex Metadata program reads would let unknown tokens show their real names. - Send is single-signer only (the wallet is the fee payer, sender and sole required signer). Multi-sig SPL transfers work via the dapp bridge (window.solana.signAndSendTransaction, which already handles partial signatures). --- bundled-addons/bchwallet/index.js | 50 ++++- bundled-addons/bchwallet/lib/chain-sol.js | 147 ++++++++++++++- bundled-addons/bchwallet/lib/sol-spl.js | 220 ++++++++++++++++++++++ bundled-addons/bchwallet/panel.html | 10 + bundled-addons/bchwallet/panel.js | 91 ++++++++- 5 files changed, 508 insertions(+), 10 deletions(-) create mode 100644 bundled-addons/bchwallet/lib/sol-spl.js diff --git a/bundled-addons/bchwallet/index.js b/bundled-addons/bchwallet/index.js index ce4fc865..a4b05fa7 100644 --- a/bundled-addons/bchwallet/index.js +++ b/bundled-addons/bchwallet/index.js @@ -51,7 +51,7 @@ async function loadDeps(api) { // base58check as encodeBase58 / decodeBase58 — expose them under a // `{encode, decode}` shape the SOL adapter reads from. const solBase58 = { encode: base58check.encodeBase58, decode: base58check.decodeBase58 }; - const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58 }); + const solAdapter = require("./lib/chain-sol.js")({ ed25519, base58: solBase58, sha256 }); // DGB delegates address derivation + PSBT to the vendored @dgb-wallet/* // packages under lib/dgb/. Those are ESM; the peer deps (bitcoinjs-lib, // bip32, ecpair, @bitcoinerlab/secp256k1) are CommonJS and reachable via @@ -660,6 +660,18 @@ function fmtValue(units, decimals) { const n = Number(units) / Math.pow(10, decimals); return n.toFixed(decimals).replace(/(\.\d*?[1-9])0+$|\.0+$/, "$1"); } +// BigInt-safe display for SPL token amounts (raw units in u64 strings). +function fmtTokenAmount(rawStr, decimals) { + const s = String(rawStr || "0"); + const neg = s.startsWith("-"); + const abs = neg ? s.slice(1) : s; + const d = Number(decimals) || 0; + if (d === 0) return (neg ? "-" : "") + abs; + const pad = abs.padStart(d + 1, "0"); + const whole = pad.slice(0, pad.length - d); + const frac = pad.slice(pad.length - d).replace(/0+$/, ""); + return (neg ? "-" : "") + whole + (frac ? "." + frac : ""); +} function registerPanelMessages(api) { api.onMessage("state", (_p, m) => { fromPanel(m); return fullState(); }); @@ -834,6 +846,42 @@ function registerPanelMessages(api) { const plan = await Promise.resolve(rt.adapter.plan(p || {})); return describePlan(plan, rt.entry.chain, rt.entry.network); }); + // SPL token plan/send — only meaningful when the selected wallet is SOL. + api.onMessage("planTokenSend", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "sol" || typeof rt.adapter.planTokenTransfer !== "function") { + throw new Error("token send is a Solana-only flow"); + } + const plan = await rt.adapter.planTokenTransfer(p || {}); + return { + recipients: plan.recipients, fee: String(plan.fee), feeRate: String(plan.feeRate), + inputs: 0, change: "0", total: plan.total, mint: plan.mint, decimals: plan.decimals, + }; + }); + api.onMessage("sendToken", async (p, m) => { + fromPanel(m); + const rt = requireSelected(); + if (rt.entry.chain !== "sol") throw new Error("token send is Solana-only"); + const plan = await rt.adapter.planTokenTransfer(p || {}); + const meta = chainMeta("sol", rt.entry.network); + const tokenInfo = (rt.adapter.snapshot().tokens || []).find((t) => t.mint === plan.mint) || {}; + const rows = [ + { label: "To", value: plan.recipients[0].to, mono: true }, + { label: "Amount", value: `${fmtTokenAmount(plan.recipients[0].value, plan.decimals)} ${tokenInfo.symbol || "token"}`, strong: true }, + { label: "Mint", value: plan.mint, mono: true }, + { label: "Network fee", value: `~${fmtValue(Number(plan.fee), meta.decimals)} SOL${(plan._spl && plan._spl.destExists === false) ? " (includes new token account rent)" : ""}` }, + { label: "Wallet", value: `${rt.entry.label} — Solana · ${rt.entry.network}` }, + ]; + const pick = await api.approvalModal({ + title: `Send ${tokenInfo.symbol || "SPL token"}?`, + origin: "Aegis wallet panel", + rows, + actions: [{ id: "send", label: "Send", primary: true }], + }); + if (pick !== "send") throw new Error("cancelled"); + return rt.adapter.signAndBroadcastToken(plan); + }); // Execute a send with approval overlay. api.onMessage("send", async (p, m) => { fromPanel(m); diff --git a/bundled-addons/bchwallet/lib/chain-sol.js b/bundled-addons/bchwallet/lib/chain-sol.js index b1be949c..8d478292 100644 --- a/bundled-addons/bchwallet/lib/chain-sol.js +++ b/bundled-addons/bchwallet/lib/chain-sol.js @@ -38,8 +38,9 @@ const NETWORKS = { // System program's address is 32 bytes of zeros; base58 is "1111...1111". const SYSTEM_PROGRAM = new Uint8Array(32); -module.exports = function makeSolAdapter({ ed25519, base58 }) { - if (!ed25519 || !base58) throw new Error("chain-sol: missing dep"); +module.exports = function makeSolAdapter({ ed25519, base58, sha256 }) { + if (!ed25519 || !base58 || !sha256) throw new Error("chain-sol: missing dep"); + const spl = require("./sol-spl.js")({ ed25519, sha256, base58 }); // ---- HMAC-SHA512 (for SLIP-0010) ------------------------------------- // Not in @noble/hashes/sha2 as a direct helper for SHA-512; @noble/hashes @@ -193,6 +194,7 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) { this._state = { balance: { confirmed: 0, unconfirmed: 0 }, history: [], + tokens: [], // [{mint, symbol, name, decimals, balance, tokenAccount, tokenProgram}] height: 0, scanning: false, error: null, @@ -214,13 +216,14 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) { balance: this._state.balance, height: this._state.height, history: this._state.history, + tokens: this._state.tokens, scanning: this._state.scanning, error: this._state.error, server: this._client.url, rpcUrl: this._client.url, explorerTx: this._net.explorerTx, explorerAddr: this._net.explorerAddr, - explorerSuffix: this._net.explorerCluster, // panel appends this when opening + explorerSuffix: this._net.explorerCluster, faucet: this._net.faucet, }; } @@ -229,14 +232,16 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) { if (this._state.scanning) return; this._state.scanning = true; this._state.error = null; this._emit(); try { - const [balRes, slot] = await Promise.all([ + const [balRes, slot, tokens] = await Promise.all([ this._client.call("getBalance", [this.address]), this._client.call("getSlot", []), + this._fetchTokens().catch((e) => { this.log("tokens fetch failed:", e?.message); return []; }), ]); // getBalance response: { context, value: lamports } const lamports = balRes && typeof balRes === "object" ? Number(balRes.value || 0) : Number(balRes || 0); this._state.balance = { confirmed: lamports, unconfirmed: 0 }; this._state.height = Number(slot || 0); + this._state.tokens = tokens; } catch (e) { this._state.error = e?.message || String(e); this.log("refresh failed:", this._state.error); @@ -245,6 +250,47 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) { this._emit(); } } + // getTokenAccountsByOwner + parse. Result shape: + // {context, value: [{ pubkey, account: {data: {parsed: {info:{mint, tokenAmount:{amount,decimals,uiAmountString}}}, program, space}, executable, ...} }]} + // We ask for jsonParsed encoding so the RPC does the layout heavy-lift. + async _fetchTokens() { + const known = spl.KNOWN_TOKENS[this._net.id] || {}; + const call = (programB58) => this._client.call("getTokenAccountsByOwner", [ + this.address, + { programId: programB58 }, + { encoding: "jsonParsed", commitment: "confirmed" }, + ]); + const results = await Promise.all([ + call(spl.TOKEN_PROGRAM_ID_B58).catch(() => ({ value: [] })), + call(spl.TOKEN_2022_PROGRAM_ID_B58).catch(() => ({ value: [] })), + ]); + const out = []; + for (let p = 0; p < results.length; p++) { + const list = results[p]?.value || []; + const isTk22 = p === 1; + for (const it of list) { + const info = it?.account?.data?.parsed?.info; + if (!info) continue; + const mint = String(info.mint || ""); + const dec = Number(info.tokenAmount?.decimals || 0); + const rawAmount = String(info.tokenAmount?.amount || "0"); + const meta = known[mint]; + out.push({ + mint, tokenAccount: String(it.pubkey || ""), + tokenProgram: isTk22 ? spl.TOKEN_2022_PROGRAM_ID_B58 : spl.TOKEN_PROGRAM_ID_B58, + symbol: meta?.symbol || mint.slice(0, 6) + "…", + name: meta?.name || null, + decimals: dec, + balance: rawAmount, // string (u64) to preserve precision + isKnown: !!meta, + isToken2022: isTk22, + }); + } + } + // Sort known tokens first, then by balance desc. + out.sort((a, b) => (b.isKnown - a.isKnown) || (BigInt(b.balance) > BigInt(a.balance) ? 1 : -1)); + return out; + } schedulePoll(ms = 20_000) { clearTimeout(this._pollTimer); this._pollTimer = setTimeout(() => this.refresh().finally(() => this.schedulePoll(ms)), ms); @@ -302,6 +348,99 @@ module.exports = function makeSolAdapter({ ed25519, base58 }) { return { txid }; } + // ---- SPL token send ----------------------------------------------- + // Build a TransferChecked (+ optional CreateAssociatedTokenAccountIdempotent) + // transaction moving `amount` (in raw token units) of a given mint to a + // recipient's ATA. `mintB58` is the mint address as a base58 string; + // decimals come from the sender's own token account (or the caller + // passes them explicitly if the sender's ATA is empty). + async planTokenTransfer({ mint, to, amount, decimals, tokenProgram }) { + const mintB58 = String(mint || ""); + const mintBytes = base58.decode(mintB58); + if (mintBytes.length !== 32) throw new Error("bad mint address"); + const recipientBytes = base58.decode(String(to || "").trim()); + if (recipientBytes.length !== 32) throw new Error("bad recipient address"); + // Resolve the token program for this mint from our own token list — + // Token-2022 mints need the 2022 program in the transfer instruction. + let tp = tokenProgram ? base58.decode(tokenProgram) : spl.TOKEN_PROGRAM_ID; + let dec = decimals; + const mine = (this._state.tokens || []).find((t) => t.mint === mintB58); + if (mine) { + tp = base58.decode(mine.tokenProgram); + if (dec == null) dec = mine.decimals; + } + if (dec == null) throw new Error("token decimals unknown — no ATA on this wallet for that mint"); + const amt = BigInt(amount); + if (amt <= 0n) throw new Error("amount must be > 0"); + if (mine && BigInt(mine.balance) < amt) throw new Error("insufficient token balance"); + const sourceATA = spl.associatedTokenAddress(this._pub, mintBytes, tp); + const destATA = spl.associatedTokenAddress(recipientBytes, mintBytes, tp); + // Ask the RPC whether the destination ATA already exists. If not, + // prepend a CreateIdempotent instruction so the transfer succeeds + // in one round-trip — the recipient never has to have interacted + // with this mint before. + const destATA_B58 = base58.encode(destATA); + const info = await this._client.call("getAccountInfo", [destATA_B58, { encoding: "base64" }]); + const destExists = !!(info && info.value); + const instructions = []; + if (!destExists) { + instructions.push(spl.createATAIdempotentInstruction({ + payer: this._pub, ata: destATA, owner: recipientBytes, mint: mintBytes, tokenProgram: tp, + })); + } + instructions.push(spl.transferCheckedInstruction({ + sourceATA, mint: mintBytes, destATA, owner: this._pub, + amount: amt, decimals: dec, tokenProgram: tp, + })); + const { blockhash } = (await this._client.call("getLatestBlockhash", [])).value || {}; + if (!blockhash) throw new Error("could not fetch a recent blockhash"); + const recentBlockhash = base58.decode(String(blockhash)); + return { + _spl: { instructions, recentBlockhash, destExists, sourceATA, destATA }, + recipients: [{ to: base58.encode(recipientBytes), value: amt.toString() }], + // Fee estimate: 5000 lamports per signature + ~2039280 rent-exempt + // if we're creating a new ATA. Real fee still comes from the network. + fee: destExists ? 5000 : 5000 + 2039280, + feeRate: 5000, + inputs: [], + change: 0, + total: amt.toString(), + mint: mintB58, + decimals: dec, + }; + } + async signAndBroadcastToken(plan) { + const sp = plan && plan._spl; + if (!sp) throw new Error("bad token plan"); + const message = spl.buildMessage({ + feePayer: this._pub, + instructions: sp.instructions, + recentBlockhash: sp.recentBlockhash, + }); + const sig = ed25519.sign(message, this._priv); + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + const sigCount = encodeCompactU16(1); + const wire = new Uint8Array(sigCount.length + 64 + message.length); + wire.set(sigCount, 0); + wire.set(sig, sigCount.length); + wire.set(message, sigCount.length + 64); + const wireB58 = base58.encode(wire); + const txid = await this._client.call("sendTransaction", [wireB58]); + if (typeof txid !== "string" || !txid.length) throw new Error("bad txid from RPC: " + JSON.stringify(txid)); + this.log("SPL broadcast", txid); + setTimeout(() => this.refresh().catch(() => {}), 4000); + return { txid }; + } + // Solana's convention: ed25519 signature over the raw message bytes, // returned as {publicKey, signature} both base58. Dapps that follow // the wallet-adapter standard verify against these. diff --git a/bundled-addons/bchwallet/lib/sol-spl.js b/bundled-addons/bchwallet/lib/sol-spl.js new file mode 100644 index 00000000..0b18fa98 --- /dev/null +++ b/bundled-addons/bchwallet/lib/sol-spl.js @@ -0,0 +1,220 @@ +// Solana Program Library (SPL) token primitives — PDA derivation, +// Associated Token Account math, and the two Token-program instructions +// this wallet needs at the bytecode level: `TransferChecked` (send SPL +// with a decimals sanity check) and `CreateAssociatedTokenAccountIdempotent` +// (make the receiver's token account inline, so the user doesn't have to +// pre-create it on any address they've never seen before). +// +// Every account address on Solana is a 32-byte ed25519 public key. A +// Program-Derived Address (PDA) is a 32-byte value that is NOT on the +// ed25519 curve — the runtime uses that fact as proof that no one holds +// its private key, so only the owning program can spend from it. To +// derive a PDA we sha256(seeds || programId || bump || "ProgramDerivedAddress") +// for bump = 255…0 and pick the first value that isn't a valid curve +// point. `isOnCurve` here defers to @noble/curves/ed25519's ExtendedPoint, +// which throws on invalid points; everything that decodes is on-curve. + +const PDA_MARKER = new TextEncoder().encode("ProgramDerivedAddress"); + +module.exports = function makeSolSpl({ ed25519, sha256, base58 }) { + if (!ed25519 || !sha256 || !base58) throw new Error("sol-spl: missing dep"); + + // ---- constants ------------------------------------------------------- + const TOKEN_PROGRAM_ID_B58 = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"; + const ASSOC_PROGRAM_ID_B58 = "ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL"; + const TOKEN_2022_PROGRAM_ID_B58 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"; + const TOKEN_PROGRAM_ID = base58.decode(TOKEN_PROGRAM_ID_B58); + const ASSOC_PROGRAM_ID = base58.decode(ASSOC_PROGRAM_ID_B58); + const TOKEN_2022_PROGRAM_ID = base58.decode(TOKEN_2022_PROGRAM_ID_B58); + const SYSTEM_PROGRAM_ID = new Uint8Array(32); + + // Known-token registry — just enough to give the panel a sensible label + // for the tokens users actually see day-to-day. Everything else falls + // back to the mint address itself (truncated in the UI). + const KNOWN_TOKENS = { + mainnet: { + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v": { symbol: "USDC", decimals: 6, name: "USD Coin" }, + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB": { symbol: "USDT", decimals: 6, name: "Tether USD" }, + "So11111111111111111111111111111111111111112": { symbol: "wSOL", decimals: 9, name: "Wrapped SOL" }, + }, + devnet: { + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU": { symbol: "USDC", decimals: 6, name: "USD Coin (devnet)" }, + }, + }; + + // ---- ed25519 curve check -------------------------------------------- + // A 32-byte pubkey is "on curve" if it decompresses to a valid Edwards + // point. @noble/curves v2 exposes Point.fromBytes(bytes) (throws on + // invalid); older versions exposed ExtendedPoint.fromHex(hex-string) + // — try each in order. + function isOnCurve(pubkey32) { + const P = ed25519.Point || ed25519.ExtendedPoint; + if (!P) return true; // no curve access — treat every value as + // on-curve (over-conservative; PDA loop falls + // through more than it should but never gets + // wrong). + try { + if (typeof P.fromBytes === "function") { P.fromBytes(pubkey32); return true; } + if (typeof P.fromHex === "function") { + const hex = Array.from(pubkey32, (b) => b.toString(16).padStart(2, "0")).join(""); + P.fromHex(hex); return true; + } + } catch { return false; } + return true; + } + + const concat = (...ps) => { + const n = ps.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); let k = 0; + for (const p of ps) { out.set(p, k); k += p.length; } + return out; + }; + const u64le = (n) => { + let v = BigInt(n); const o = new Uint8Array(8); + for (let i = 0; i < 8; i++) { o[i] = Number(v & 0xffn); v >>= 8n; } + return o; + }; + const encodeCompactU16 = (n) => { + const out = []; + let rem = n; + while (true) { + let byte = rem & 0x7f; rem >>= 7; + if (rem === 0) { out.push(byte); break; } + byte |= 0x80; out.push(byte); + } + return Uint8Array.from(out); + }; + + // ---- PDA / ATA ------------------------------------------------------- + function findProgramAddress(seeds, programId) { + for (let bump = 255; bump >= 0; bump--) { + const material = concat( + ...seeds.map((s) => Uint8Array.from(s)), + Uint8Array.from([bump]), + programId, + PDA_MARKER, + ); + const candidate = sha256(material); + if (!isOnCurve(candidate)) return { address: candidate, bump }; + } + throw new Error("no PDA found (unreachable)"); + } + // Standard ATA = PDA under ASSOC_PROGRAM_ID with seeds + // [ownerPubkey, TOKEN_PROGRAM_ID, mint]. + // We match the seed layout the @solana/spl-token library uses so + // addresses agree with any wallet or block explorer. + function associatedTokenAddress(owner, mint, tokenProgram = TOKEN_PROGRAM_ID) { + return findProgramAddress([owner, tokenProgram, mint], ASSOC_PROGRAM_ID).address; + } + + // ---- instruction encoders ------------------------------------------- + // SPL Token: TransferChecked (discriminator 12) — asserts amount+decimals + // against the mint so a UI bug can't move 1000× the intended value. + // accounts: [sourceATA (writable), mint (readonly), destATA (writable), owner (signer)] + // data: [12, amount:u64_le, decimals:u8] + function transferCheckedInstruction({ sourceATA, mint, destATA, owner, amount, decimals, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: tokenProgram, + keys: [ + { pubkey: sourceATA, isSigner: false, isWritable: true }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: destATA, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: true, isWritable: false }, + ], + data: concat(Uint8Array.from([12]), u64le(amount), Uint8Array.from([decimals])), + }; + } + // Associated Token Account program: CreateIdempotent (discriminator 1) + // accounts: [payer(signer,writable), ata(writable), owner(readonly), + // mint(readonly), systemProgram(readonly), tokenProgram(readonly)] + // data: [1] (idempotent variant — no-op if the account exists) + function createATAIdempotentInstruction({ payer, ata, owner, mint, tokenProgram = TOKEN_PROGRAM_ID }) { + return { + programId: ASSOC_PROGRAM_ID, + keys: [ + { pubkey: payer, isSigner: true, isWritable: true }, + { pubkey: ata, isSigner: false, isWritable: true }, + { pubkey: owner, isSigner: false, isWritable: false }, + { pubkey: mint, isSigner: false, isWritable: false }, + { pubkey: SYSTEM_PROGRAM_ID, isSigner: false, isWritable: false }, + { pubkey: tokenProgram, isSigner: false, isWritable: false }, + ], + data: Uint8Array.from([1]), + }; + } + + // ---- transaction message builder ------------------------------------ + // Assembles the raw Solana message bytes for a single-fee-payer, single- + // signer transaction that may carry multiple instructions. Account keys + // are sorted per Solana's account-classification rules (writable-signed, + // readonly-signed, writable-unsigned, readonly-unsigned). + function buildMessage({ feePayer, instructions, recentBlockhash }) { + // 1. Collect every unique pubkey mentioned across instructions + + // include the fee payer + include each instruction's programId. + const keys = new Map(); // b58 → { pubkey, isSigner, isWritable } + const upsert = (pubkey, isSigner, isWritable) => { + const k = base58.encode(pubkey); + const cur = keys.get(k) || { pubkey, isSigner: false, isWritable: false }; + cur.isSigner = cur.isSigner || isSigner; + cur.isWritable = cur.isWritable || isWritable; + keys.set(k, cur); + }; + upsert(feePayer, true, true); + for (const ins of instructions) { + for (const k of ins.keys) upsert(k.pubkey, k.isSigner, k.isWritable); + upsert(ins.programId, false, false); + } + // 2. Classify + sort into the four buckets. + const bucket = { ws: [], rs: [], wu: [], ru: [] }; + for (const v of keys.values()) { + if (v.isSigner && v.isWritable) bucket.ws.push(v); + else if (v.isSigner) bucket.rs.push(v); + else if (v.isWritable) bucket.wu.push(v); + else bucket.ru.push(v); + } + // Fee payer MUST be at index 0 (Solana requires the first signer to + // be writable & to pay the fee). + const payerB58 = base58.encode(feePayer); + bucket.ws.sort((a, b) => (base58.encode(a.pubkey) === payerB58 ? -1 : base58.encode(b.pubkey) === payerB58 ? 1 : 0)); + const ordered = [...bucket.ws, ...bucket.rs, ...bucket.wu, ...bucket.ru]; + // 3. Encode the message. + const header = Uint8Array.from([ + bucket.ws.length + bucket.rs.length, // numRequiredSignatures + bucket.rs.length, // numReadonlySignedAccounts + bucket.ru.length, // numReadonlyUnsignedAccounts + ]); + const keysSection = concat( + encodeCompactU16(ordered.length), + ...ordered.map((v) => Uint8Array.from(v.pubkey)), + ); + const indexOf = new Map(ordered.map((v, i) => [base58.encode(v.pubkey), i])); + const insSection = concat( + encodeCompactU16(instructions.length), + ...instructions.map((ins) => { + const programIndex = indexOf.get(base58.encode(ins.programId)); + const accountBytes = Uint8Array.from(ins.keys.map((k) => indexOf.get(base58.encode(k.pubkey)))); + return concat( + Uint8Array.from([programIndex]), + encodeCompactU16(accountBytes.length), + accountBytes, + encodeCompactU16(ins.data.length), + ins.data, + ); + }), + ); + return concat(header, keysSection, recentBlockhash, insSection); + } + + return { + // constants + TOKEN_PROGRAM_ID, ASSOC_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, SYSTEM_PROGRAM_ID, + TOKEN_PROGRAM_ID_B58, ASSOC_PROGRAM_ID_B58, + KNOWN_TOKENS, + // helpers + isOnCurve, findProgramAddress, associatedTokenAddress, + // instruction encoders + transferCheckedInstruction, createATAIdempotentInstruction, + // tx assembly + buildMessage, + }; +}; diff --git a/bundled-addons/bchwallet/panel.html b/bundled-addons/bchwallet/panel.html index 2d2c0237..06810402 100644 --- a/bundled-addons/bchwallet/panel.html +++ b/bundled-addons/bchwallet/panel.html @@ -152,8 +152,18 @@ +