feat(theseus/aegis): SPL token support (view balances + send)
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 / <each token>) 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).
This commit is contained in:
parent
1b29706ba4
commit
b5f7552277
5 changed files with 508 additions and 10 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
220
bundled-addons/bchwallet/lib/sol-spl.js
Normal file
220
bundled-addons/bchwallet/lib/sol-spl.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -152,8 +152,18 @@
|
|||
<button class="btn sm" id="openFaucet" hidden>Faucet</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="tokensCard" hidden style="margin-top:12px">
|
||||
<div class="lbl">Tokens</div>
|
||||
<div id="tokensList" class="kv"></div>
|
||||
<div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="tab-send" hidden>
|
||||
<div class="field" id="sendAssetField" hidden>
|
||||
<div class="lbl">Asset</div>
|
||||
<select id="sendAsset"></select>
|
||||
<div class="hint">Pick the native coin or an SPL token in this wallet.</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="lbl">Recipient</div>
|
||||
<input type="text" id="sendTo" spellcheck="false" autocomplete="off" placeholder="…">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ let sendMax = false;
|
|||
let planTimer = null;
|
||||
let lastPlan = null;
|
||||
let settingsFilled = false;
|
||||
// Selected asset for the Send tab. `null` = native coin. Otherwise a
|
||||
// { mint, symbol, decimals } picked from the SOL wallet's SPL token list.
|
||||
let sendAsset = null;
|
||||
|
||||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
||||
const hostOf = (url) => { try { return new URL(url).host || url; } catch { return url; } };
|
||||
|
|
@ -282,15 +285,79 @@ function render() {
|
|||
$("addrMeta").textContent = s.addressPath ? "· " + s.addressPath : "";
|
||||
$("nextAddr").hidden = chain() !== "bch";
|
||||
$("openFaucet").hidden = !s.faucet;
|
||||
// Render SPL tokens list (SOL wallets only). Sending a token clicks
|
||||
// through to the Send tab with that asset pre-picked.
|
||||
renderTokens();
|
||||
applyUnitPicker();
|
||||
$("feeField").hidden = chain() !== "bch";
|
||||
renderHistory();
|
||||
}
|
||||
|
||||
function renderTokens() {
|
||||
const s = sel();
|
||||
const tokens = (chain() === "sol" && s?.tokens) || [];
|
||||
const card = $("tokensCard");
|
||||
card.hidden = tokens.length === 0;
|
||||
if (!tokens.length) return;
|
||||
const el = $("tokensList");
|
||||
el.innerHTML = tokens.map((t) => {
|
||||
const dec = Number(t.decimals) || 0;
|
||||
const bal = fmtTokenAmount(t.balance, dec);
|
||||
return `<div class="tx" style="grid-template-columns:1fr auto auto;cursor:default">
|
||||
<div><div>${esc(t.symbol)}${t.name ? ' <span class="hint">' + esc(t.name) + '</span>' : ""}</div><div class="hint mono">${esc(t.mint.slice(0, 10))}…${esc(t.mint.slice(-6))}</div></div>
|
||||
<div class="amt2 in" style="align-self:center">${esc(bal)}</div>
|
||||
<button class="btn sm" data-mint="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${dec}" style="align-self:center">Send</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
el.querySelectorAll("button[data-mint]").forEach((b) => b.addEventListener("click", () => {
|
||||
sendAsset = { mint: b.dataset.mint, symbol: b.dataset.symbol, decimals: Number(b.dataset.decimals) };
|
||||
showTab("send");
|
||||
}));
|
||||
}
|
||||
// Same shape as index.js's fmtTokenAmount — string-safe for u64 SPL amounts.
|
||||
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 applyUnitPicker() {
|
||||
const s = sel(); if (!s) return;
|
||||
if (!unit) unit = "big";
|
||||
const big = bigUnitLabel(), small = smallUnitLabel();
|
||||
// ---- SPL asset picker (SOL wallets with tokens) ---------------------
|
||||
const tokens = (chain() === "sol" && s.tokens) || [];
|
||||
const assetField = $("sendAssetField");
|
||||
if (tokens.length) {
|
||||
assetField.hidden = false;
|
||||
const sel_ = $("sendAsset");
|
||||
// Rebuild whenever the asset set changes so a new token appears.
|
||||
const key = tokens.map((t) => t.mint).join("|");
|
||||
if (sel_.dataset.key !== key) {
|
||||
sel_.dataset.key = key;
|
||||
sel_.innerHTML = `<option value="">SOL — native</option>` + tokens.map((t) =>
|
||||
`<option value="${esc(t.mint)}" data-symbol="${esc(t.symbol)}" data-decimals="${Number(t.decimals) || 0}">${esc(t.symbol)}${t.name ? " · " + esc(t.name) : ""}</option>`
|
||||
).join("");
|
||||
sel_.onchange = () => {
|
||||
const opt = sel_.options[sel_.selectedIndex];
|
||||
sendAsset = opt && opt.value ? { mint: opt.value, symbol: opt.dataset.symbol, decimals: Number(opt.dataset.decimals) } : null;
|
||||
applyUnitPicker(); schedulePlan();
|
||||
};
|
||||
}
|
||||
// Reflect the current sendAsset back into the select.
|
||||
sel_.value = sendAsset ? sendAsset.mint : "";
|
||||
} else {
|
||||
assetField.hidden = true;
|
||||
sendAsset = null;
|
||||
}
|
||||
const isToken = sendAsset != null;
|
||||
const big = isToken ? sendAsset.symbol : bigUnitLabel();
|
||||
const small = isToken ? "raw" : smallUnitLabel();
|
||||
$("unitPicker").innerHTML =
|
||||
`<button data-u="big" class="${unit === "big" ? "on" : ""}" type="button">${esc(big)}</button>` +
|
||||
`<button data-u="small" class="${unit === "small" ? "on" : ""}" type="button">${esc(small)}</button>`;
|
||||
|
|
@ -316,8 +383,10 @@ function setUnit(u) {
|
|||
function amountUnits() {
|
||||
const raw = $("sendAmt").value.trim().replace(/,/g, "");
|
||||
if (!raw) return 0;
|
||||
const d = decimals();
|
||||
const bigDecimals = d > 15; // SC (24) needs BigInt to preserve precision.
|
||||
// For SPL tokens the amount is a raw u64 string in the token's own
|
||||
// smallest unit — same BigInt-safe path SC uses.
|
||||
const d = sendAsset ? Number(sendAsset.decimals) || 0 : decimals();
|
||||
const bigDecimals = sendAsset != null || d > 15;
|
||||
if (unit === "small") {
|
||||
if (bigDecimals) return raw.replace(/\D+/g, "") || "0";
|
||||
return Math.round(Number(raw));
|
||||
|
|
@ -325,7 +394,6 @@ function amountUnits() {
|
|||
const [w, f = ""] = raw.split(".");
|
||||
const frac = (f + "0".repeat(d)).slice(0, d);
|
||||
if (bigDecimals) {
|
||||
// "1.234" (24 dp) → BigInt("1000000000000000000000000") + BigInt("234000…")
|
||||
const total = (BigInt(w || "0") * (10n ** BigInt(d))) + BigInt(frac || "0");
|
||||
return total.toString();
|
||||
}
|
||||
|
|
@ -435,6 +503,16 @@ async function updatePlan() {
|
|||
$("sendToHint").textContent = "";
|
||||
if (!to || (!sendMax && !amountUnits())) return;
|
||||
try {
|
||||
if (sendAsset) {
|
||||
// SPL token flow — amount is raw units of the token's decimals.
|
||||
const p = await S.invoke("planTokenSend", { mint: sendAsset.mint, to, amount: amountUnits() });
|
||||
lastPlan = { _token: true, ...p };
|
||||
$("sumAmt").textContent = fmtTokenAmount(p.recipients[0].value, sendAsset.decimals) + " " + sendAsset.symbol;
|
||||
$("sumFee").textContent = fmtBig(p.fee, decimals()) + " SOL";
|
||||
$("sumTotal").textContent = fmtTokenAmount(p.total, sendAsset.decimals) + " " + sendAsset.symbol;
|
||||
$("sendBtn").disabled = false;
|
||||
return;
|
||||
}
|
||||
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
|
||||
const p = await S.invoke("planSend", { to, amount: amountUnits(), feeRate, sendMax });
|
||||
lastPlan = p;
|
||||
|
|
@ -455,8 +533,11 @@ $("sendBtn").addEventListener("click", async () => {
|
|||
const msg = $("sendMsg"); msg.hidden = true;
|
||||
$("sendBtn").disabled = true; $("sendBtn").textContent = "Waiting for approval…";
|
||||
try {
|
||||
const isToken = sendAsset && lastPlan._token;
|
||||
const feeRate = chain() === "bch" ? Number($("feeRate").value) : undefined;
|
||||
const r = await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax });
|
||||
const r = isToken
|
||||
? await S.invoke("sendToken", { mint: sendAsset.mint, to: $("sendTo").value.trim(), amount: amountUnits() })
|
||||
: await S.invoke("send", { to: $("sendTo").value.trim(), amount: amountUnits(), feeRate, sendMax });
|
||||
msg.className = "msg ok";
|
||||
msg.innerHTML = `Sent. <a class="link" data-tx="${esc(r.txid)}">${esc(r.txid.slice(0, 16))}…</a>`;
|
||||
msg.querySelector("a").addEventListener("click", () => openUrl(explorerHref(sel().explorerTx, r.txid)));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue