micro-sol-signer
Version:
Create, sign & decode Solana transactions with minimum deps
491 lines • 19.8 kB
JavaScript
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