UNPKG

micro-sol-signer

Version:

Create, sign & decode Solana transactions with minimum deps

491 lines 19.8 kB
import { ed25519 } from '@noble/curves/ed25519.js'; import { base58, base64, hex } from '@scure/base'; import * as P from 'micro-packed'; import * as idl from "./idl/index.js"; import { Decimal, PRECISION, pubKey, shortU16 } from "./idl/index.js"; // System: solana IDLs import ALTIDL from "./idl/alt.js"; import ComputeBudgetIDL from "./idl/computeBudget.js"; import ConfigIDL from "./idl/config.js"; import MemoIDL from "./idl/memo.js"; import SystemIDL from "./idl/system.js"; import TokenIDL from "./idl/token.js"; import Token2022IDL from "./idl/token2022.js"; export { Offchain } from "./offchain.js"; export { Decimal, PRECISION, pubKey, shortU16 }; const MAX_TX_SIZE = 1280 - 40 - 8; function removeUndefined(obj) { if (obj === null || obj === undefined) return obj; if (typeof obj !== 'object') return obj; if (Array.isArray(obj)) return obj.map((item) => removeUndefined(item)); const res = {}; for (const [key, value] of Object.entries(obj)) { if (value === undefined) continue; res[key] = removeUndefined(value); } return res; } export function validateAddress(address) { const pubkey = base58.decode(address); if (pubkey.length !== 32) throw new Error('Invalid Solana address'); } const keyParams = (i, req, signed, unsigned, total) => ({ sign: i < req ? true : false, write: i < req - signed || (i >= req && i < total - unsigned) ? true : false, }); const MessageHeader = P.struct({ requiredSignatures: P.U8, readSigned: P.U8, readUnsigned: P.U8, }); const Instruction = P.struct({ programIdx: P.U8, keys: P.array(shortU16, P.U8), data: P.bytes(shortU16), }); const MessageLegacy = P.struct({ header: MessageHeader, keys: P.array(shortU16, pubKey), blockhash: pubKey, instructions: P.array(shortU16, Instruction), }); const MessageAddressTableLookup = P.struct({ account: pubKey, writableIndexes: P.array(shortU16, P.U8), readonlyIndexes: P.array(shortU16, P.U8), }); const MessageV0 = P.struct({ header: MessageHeader, keys: P.array(shortU16, pubKey), blockhash: pubKey, instructions: P.array(shortU16, Instruction), ALT: P.array(shortU16, MessageAddressTableLookup), }); const MessageVersion = P.wrap({ encodeStream(w, value) { if (value === 'legacy') { // legacy is empty! } else if (typeof value === 'number') { if (value < 0 || value > 127) throw new Error('Invalid message version'); w.byte(0x80 | value); } else throw new Error('Invalid message version type'); }, decodeStream(r) { const b = r.byte(true); if ((b & 0x80) === 0) return 'legacy'; r.byte(); // move cursor return b & 0x7f; }, }); export const MessageRaw = P.tag(MessageVersion, { legacy: MessageLegacy, 0: MessageV0, }); const getAccountKeys = (msg) => { const accounts = []; for (let i = 0; i < msg.data.keys.length; i++) { accounts.push({ address: msg.data.keys[i], ...keyParams(i, msg.data.header.requiredSignatures, msg.data.header.readSigned, msg.data.header.readUnsigned, msg.data.keys.length), }); } if (!accounts.length) throw new Error('SOL.tx: empty accounts array'); if (msg.TAG !== 'legacy') { for (const alt of msg.data.ALT) { for (const idx of alt.writableIndexes) accounts.push({ address: `${alt.account}:${idx}`, write: true, sign: false }); } for (const alt of msg.data.ALT) { for (const idx of alt.readonlyIndexes) accounts.push({ address: `${alt.account}:${idx}`, write: false, sign: false }); } } return accounts; }; const MessageCoder = { encode(msg) { const accounts = getAccountKeys(msg); return { version: msg.TAG, feePayer: accounts[0].address, blockhash: msg.data.blockhash, instructions: msg.data.instructions.map((i) => ({ program: accounts[i.programIdx].address, keys: i.keys.map((j) => accounts[j]), data: i.data, })), }; }, decode(to) { const { version, feePayer, blockhash, instructions } = to; const accounts = new Map(); // contract -> idx -> isWrite const ALTaccounts = {}; const add = (address, sign, write) => { if (address.includes(':')) { if (version === 'legacy') throw new Error('SOL.tx: cannot use AddressLookupTable addresses in legacy tx'); if (sign) throw new Error('SOL.tx: cannot sign with address for AddressLookupTable'); const [contract, idx] = address.split(':'); if (!ALTaccounts[contract]) ALTaccounts[contract] = new Map(); // JS quirk: Object keys is always insert order unless they are "numeric" (even if string!) // so '1' will always be on top, breaking insert order guarantess and introducing fingerprinting in tx // This also breaks encode(decode). Fortunately we have Map-s if (!ALTaccounts[contract].has(idx)) ALTaccounts[contract].set(idx, write); return; } if (!accounts.has(address)) accounts.set(address, { sign: false, write: false }); const acc = accounts.get(address); acc.write ||= write; acc.sign ||= sign; }; add(feePayer, true, true); for (const i of instructions) { add(i.program, false, false); for (let k of i.keys) add(k.address, k.sign, k.write); } const _keys = Array.from(accounts.keys()); // [feePayer, ...sign+write, ...sign+read, ...nosign+write, ...nosign+read] const keys = [ feePayer, ..._keys.filter((i) => accounts.get(i).sign && accounts.get(i).write && i !== feePayer), ..._keys.filter((i) => accounts.get(i).sign && !accounts.get(i).write), ..._keys.filter((i) => !accounts.get(i).sign && accounts.get(i).write), ..._keys.filter((i) => !accounts.get(i).sign && !accounts.get(i).write), ]; let requiredSignatures = 0; let readSigned = 0; let readUnsigned = 0; for (let k of keys) { if (accounts.get(k).sign) requiredSignatures++; if (accounts.get(k).write) continue; if (accounts.get(k).sign) readSigned++; else readUnsigned++; } const header = { requiredSignatures, readSigned, readUnsigned }; const ALT = []; if (version !== 'legacy') { const contractNames = Object.keys(ALTaccounts).sort(); for (const account of contractNames) { const writableIndexes = []; const readonlyIndexes = []; for (const k of ALTaccounts[account].keys()) { (ALTaccounts[account].get(k) ? writableIndexes : readonlyIndexes).push(+k); } ALT.push({ account, writableIndexes, readonlyIndexes }); } } const accountKeys = getAccountKeys({ TAG: version, data: { header, keys, ALT } }); const accountMap = Object.fromEntries(accountKeys.map((i, j) => [i.address, j])); const getKey = (address) => { const value = accountMap[address]; if (value === undefined) throw new Error('SOL.tx: unknown address: ' + address); return value; }; return { TAG: version, data: { header, keys, instructions: instructions.map((i) => ({ programIdx: getKey(i.program), keys: i.keys.map((i) => getKey(i.address)), data: i.data, })), blockhash, ALT: ALT, }, }; }, }; export const Message = P.apply(MessageRaw, MessageCoder); export const TransactionRaw = P.struct({ signatures: P.array(shortU16, P.bytes(64)), msg: MessageRaw, }); export const Transaction = P.apply(TransactionRaw, { encode(from) { const { signatures, msg } = from; if (signatures.length !== msg.data.header.requiredSignatures) throw new Error('SOL.tx: not enough signatures'); return { signatures: Object.fromEntries(signatures.map((i, j) => [msg.data.keys[j], i])), msg: MessageCoder.encode(msg), }; }, decode(to) { const raw = MessageCoder.decode(to.msg); const signatures = []; for (let i = 0; i < raw.data.header.requiredSignatures; i++) { const address = raw.data.keys[i]; const sig = to.signatures[address]; // NOTE: this will break on unsigned transactions! Where we can check this? // if (sig === undefined) throw new Error('SOL.tx: missing signature for address: ' + address); signatures.push(sig === undefined ? new Uint8Array(64) : sig); } return { signatures, msg: raw }; }, }); // Tables is like {contract: [addr1, addr2]} (from archive.getAddressLookupTable().addresses) export function AddressLookupTables(tables) { // XXX:1 -> YYY const direct = new Map(); // YYY -> XXX:1 const reverse = new Map(); for (const k in tables) { const t = tables[k]; for (let i = 0; i < t.length; i++) { const contract = `${k}:${i}`; const address = t[i]; direct.set(contract, address); // Order of contracts == priority if (!reverse.has(address)) reverse.set(address, contract); } } const mapInstructions = (tx, fn) => { const instructions = tx.msg.instructions.map((i) => ({ program: fn(i.program), keys: i.keys.map((j) => ({ ...j, address: fn(j.address) })), data: i.data, })); return { signatures: tx.signatures, msg: { ...tx.msg, instructions } }; }; return { // resolve addresses in transaction using provided tables resolve: (tx) => mapInstructions(tx, (k) => (direct.has(k) ? direct.get(k) : k)), // compresses addresses using tables compress(tx) { const blacklist = new Set(); blacklist.add(tx.msg.feePayer); for (const i of tx.msg.instructions) { for (const k of i.keys) if (k.sign) blacklist.add(k.address); } return mapInstructions(tx, (k) => !reverse.has(k) || blacklist.has(k) ? k : reverse.get(k)); }, }; } export const PROGRAMS = { ...idl.defineIDL(SystemIDL), ...idl.defineIDL(TokenIDL), ...idl.defineIDL(Token2022IDL), ...idl.defineIDL(ALTIDL), ...idl.defineIDL(ComputeBudgetIDL), ...idl.defineIDL(ConfigIDL), ...idl.defineIDL(MemoIDL), }; // Old API compat export const sys = PROGRAMS.system.program.instructions.encoders; export const token = PROGRAMS.token.program.instructions.encoders; // TODO: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. export const token2022 = PROGRAMS['token-2022'].program.instructions.encoders; export const associatedToken = PROGRAMS.token.additionalPrograms.associatedToken.instructions.encoders; export const SYS_PROGRAM = PROGRAMS.system.program.contract; export const TOKEN_PROGRAM = PROGRAMS.token.program.contract; export const TOKEN_PROGRAM2022 = PROGRAMS['token-2022'].program.contract; export const ASSOCIATED_TOKEN_PROGRAM = PROGRAMS.token.additionalPrograms.associatedToken.contract; export const tokenAddress = PROGRAMS.token.additionalPrograms.associatedToken.pdas.associatedToken; export const TokenAccount = PROGRAMS.token.program.accounts.decoder; export const AddressTableLookupData = PROGRAMS.addressLookupTable.program.accounts.decoder; export const isOnCurve = idl.isOnCurve; export const programAddress = idl.programAddress; const TOKENS_ENCODE = { [TOKEN_PROGRAM]: PROGRAMS.token.program.instructions.encoders, [TOKEN_PROGRAM2022]: PROGRAMS['token-2022'].program.instructions.encoders, }; const ACCOUNTS_DECODE = { [SYS_PROGRAM]: PROGRAMS.system.program.accounts.decoder, [TOKEN_PROGRAM]: PROGRAMS.token.program.accounts.decoder, [TOKEN_PROGRAM2022]: PROGRAMS['token-2022'].program.accounts.decoder, [ASSOCIATED_TOKEN_PROGRAM]: PROGRAMS.token.additionalPrograms.associatedToken.accounts.decoder, [PROGRAMS.addressLookupTable.program.contract]: PROGRAMS.addressLookupTable.program.accounts.decoder, [PROGRAMS.computeBudget.program.contract]: PROGRAMS.computeBudget.program.accounts.decoder, [PROGRAMS.solanaConfig.program.contract]: PROGRAMS.solanaConfig.program.accounts.decoder, [PROGRAMS.memo.program.contract]: PROGRAMS.memo.program.accounts.decoder, }; export function decodeAccount(contract, data) { if (ACCOUNTS_DECODE[contract] === undefined) throw new Error('unknown contract'); return removeUndefined(ACCOUNTS_DECODE[contract](data)); } const REGISTRY = { [SYS_PROGRAM]: PROGRAMS.system.program.instructions.decoder, [TOKEN_PROGRAM]: PROGRAMS.token.program.instructions.decoder, [TOKEN_PROGRAM2022]: PROGRAMS['token-2022'].program.instructions.decoder, [ASSOCIATED_TOKEN_PROGRAM]: PROGRAMS.token.additionalPrograms.associatedToken.instructions.decoder, [PROGRAMS.addressLookupTable.program.contract]: PROGRAMS.addressLookupTable.program.instructions.decoder, [PROGRAMS.computeBudget.program.contract]: PROGRAMS.computeBudget.program.instructions.decoder, [PROGRAMS.solanaConfig.program.contract]: PROGRAMS.solanaConfig.program.instructions.decoder, [PROGRAMS.memo.program.contract]: PROGRAMS.memo.program.instructions.decoder, }; export function parseInstruction(instruction) { if (REGISTRY[instruction.program] === undefined) throw new Error('unknown contract'); return removeUndefined(REGISTRY[instruction.program](instruction)); } export const CONTRACTS = { [SYS_PROGRAM]: PROGRAMS.system.program, [TOKEN_PROGRAM]: PROGRAMS.token.program, [TOKEN_PROGRAM2022]: PROGRAMS['token-2022'].program, [ASSOCIATED_TOKEN_PROGRAM]: PROGRAMS.token.additionalPrograms.associatedToken, [PROGRAMS.addressLookupTable.program.contract]: PROGRAMS.addressLookupTable.program, [PROGRAMS.computeBudget.program.contract]: PROGRAMS.computeBudget.program, [PROGRAMS.solanaConfig.program.contract]: PROGRAMS.solanaConfig.program, [PROGRAMS.memo.program.contract]: PROGRAMS.memo.program, }; export function verifyTx(tx) { if (typeof tx === 'string') tx = base64.decode(tx); if (tx.length > MAX_TX_SIZE) throw new Error('sol: transaction too big'); const raw = TransactionRaw.decode(tx); const msg = MessageRaw.encode(raw.msg); for (let i = 0; i < raw.msg.data.header.requiredSignatures; i++) { const address = raw.msg.data.keys[i]; const pubKey = base58.decode(address); const sig = raw.signatures[i]; if (!ed25519.verify(sig, msg, pubKey)) throw new Error(`sol: invalid signature sig=${sig} msg=${msg}`); } } export function getPublicKey(privateKey) { return ed25519.getPublicKey(privateKey); } export function getAddress(privateKey) { const publicKey = getPublicKey(privateKey); return base58.encode(publicKey); } export function formatPrivate(privateKey, format = 'base58') { const publicKey = getPublicKey(privateKey); const fullKey = P.utils.concatBytes(privateKey, publicKey); switch (format) { case 'base58': { return base58.encode(fullKey); } case 'hex': { return hex.encode(fullKey); } case 'array': { return Array.from(fullKey); } default: { throw new Error('sol: unsupported format'); } } } export function formatPublic(publicKey) { return base58.encode(publicKey); } export function parseAddress(address) { return base58.decode(address); } export function createTx(address, instructions, blockhash, version = 0) { if (!instructions.length) throw new Error('SOLPublic: empty instructions array'); return base64.encode(Transaction.encode({ msg: { version, feePayer: address, blockhash, instructions }, signatures: {}, })); } export function createTransferSol(from, to, amount, blockhash, version = 0) { return createTx(from, [sys.transferSol({ source: from, destination: to, amount })], blockhash, version); } export function createTokenTransfer(mint, from, to, amount, blockhash, tokenProgram = TOKEN_PROGRAM, version = 0) { if (TOKENS_ENCODE[tokenProgram] === undefined) throw new Error('unknown program'); return createTx(from, [ TOKENS_ENCODE[tokenProgram].transfer({ source: tokenAddress({ mint, owner: from, tokenProgram, }), destination: tokenAddress({ mint, owner: to, tokenProgram, }), authority: from, amount, }), ], blockhash, version); } export function createTokenTransferChecked(mint, from, to, amount, decimals, blockhash, tokenProgram = TOKEN_PROGRAM, version = 0) { if (TOKENS_ENCODE[tokenProgram] === undefined) throw new Error('unknown program'); return createTx(from, [ TOKENS_ENCODE[tokenProgram].transferChecked({ source: tokenAddress({ mint, owner: from, tokenProgram, }), amount, decimals, mint, authority: from, destination: tokenAddress({ mint, owner: to, tokenProgram, }), }), ], blockhash, version); } export function signTx(privateKey, data) { if (typeof data === 'string') data = base64.decode(data); const address = getAddress(privateKey); const raw = TransactionRaw.decode(data); const reqSignatures = raw.msg.data.keys.slice(0, raw.msg.data.header.requiredSignatures); if (!reqSignatures.filter((i) => i == address).length) throw new Error(`SOLPrivate: tx doesn't require signature for address=${address}`); const sig = ed25519.sign(MessageRaw.encode(raw.msg), privateKey); for (let i = 0; i < reqSignatures.length; i++) if (reqSignatures[i] === address) raw.signatures[i] = sig; // Base58 encoding for tx is deprecated const tx = base64.encode(TransactionRaw.encode(raw)); // first signature is txHash return [base58.encode(sig), tx]; } /** * Warning: It is NOT secure to sign random msgs, * because someone can create a message which is an encoded transaction. */ export function signBytes(privateKey, msg) { return base58.encode(ed25519.sign(msg, privateKey)); } export function verifyBytes(sigature, publicKey, msg) { if (typeof publicKey === 'string') publicKey = base58.decode(publicKey); return ed25519.verify(base58.decode(sigature), msg, publicKey); } export function getMessageFromTransaction(tx) { const raw = TransactionRaw.decode(base64.decode(tx)); return base64.encode(MessageRaw.encode(raw.msg)); } //# sourceMappingURL=index.js.map