micro-sol-signer
Version:
Create, sign & decode Solana transactions with minimum deps
351 lines • 12.7 kB
JavaScript
import { base58, base64 } from '@scure/base';
import * as sol from "./index.js";
// These seem official, but trigger rate-limit easily.
// Paid one starts from $500, self-hosted will require 100+ TBs of storage.
export const URL = 'https://api.mainnet-beta.solana.com';
export const TESTNET_URL = 'https://api.devnet.solana.com';
function mapToken(item, keys) {
return {
address: keys[item.accountIndex],
contract: item.mint,
owner: item.owner,
amount: BigInt(item.uiTokenAmount.amount),
decimals: item.uiTokenAmount.decimals,
};
}
// smallest first
function sortMulti(lst, ...keys) {
return lst.sort((a, b) => {
for (const k of keys) {
if (a[k] < b[k])
return -1;
if (a[k] > b[k])
return 1;
}
return 0;
});
}
function decodeData(data) {
if (!Array.isArray(data))
return data; // json
const [_data, encoding] = data;
if (encoding === 'base64')
return base64.decode(_data);
if (encoding === 'base58')
return base58.decode(_data);
throw new Error('unsupported encoding');
}
export class ArchiveNodeProvider {
rpc;
constructor(rpc) {
this.rpc = rpc;
}
async base64Call(method, ...params) {
const res = await this.rpc.call(method, ...params, {
encoding: 'base64',
commitment: 'confirmed',
});
return res.value;
}
async jsonCall(method, ...params) {
const res = await this.rpc.call(method, ...params, {
encoding: 'jsonParsed',
commitment: 'confirmed',
});
return res.value;
}
/**
* Requests airdrop SOL for tests (testnet)
* @param to - Solana address
* @param amount - Lamports amount
* @returns
*/
airdrop(to, amount) {
return this.base64Call('requestAirdrop', to, Number(amount));
}
/**
* Returns all information associated with the account of provided address
* @param address
*/
async accountInfo(address) {
if (typeof address !== 'string')
throw new Error(`accountInfo: wrong address=${address}`);
const res = await this.base64Call('getAccountInfo', address);
if (res === null)
return undefined;
const data = decodeData(res.data);
return {
lamports: BigInt(res.lamports),
owner: res.owner,
rentEpoch: res.rentEpoch,
data: data,
exec: !!res.executable,
};
}
/**
* Checks if account is valid token account (required to send tokens)
* @param mint token contract
* @param address address to check
* @param owner check if owner of token account is specific address
* @returns true if valid
*/
async isValidTokenAccount(mint, address, owner) {
const info = await this.accountInfo(address);
if (!info)
return false;
if (info.owner !== sol.TOKEN_PROGRAM)
return false;
try {
const dataFull = sol.TokenAccount(info.data);
if (dataFull.TAG !== 'token')
return false;
const data = dataFull.data;
if (data.mint !== mint)
return false;
if (data.state.TAG !== 'initialized')
return false;
if (owner !== undefined && data.owner !== owner)
return false;
return true;
}
catch (e) {
return false;
}
}
/**
* Returns minimum balance required to make account rent exempt.
* @param size - Account data length (bytes)
* @returns
*/
minBalance(size) {
if (!Number.isSafeInteger(size))
throw new Error(`minBalance: wrong size=${size}`);
return this.rpc.call('getMinimumBalanceForRentExemption', size);
}
/**
* Recent blockhash and fee information
*/
recentBlockHash() {
return this.base64Call('getRecentBlockhash');
}
async height() {
const res = await this.rpc.call('getRecentBlockhash');
return res.context.slot;
}
/**
* Latest fee (lamports per signature)
*/
async fee() {
return BigInt((await this.recentBlockHash()).feeCalculator.lamportsPerSignature);
}
async getAddressLookupTable(address) {
const res = await this.accountInfo(address);
if (!res || res.owner !== 'AddressLookupTab1e1111111111111111111111111')
throw new Error('wrong contract');
return sol.AddressTableLookupData(res.data);
}
/**
* Returns account balance and latest blockhash (required to create new transaction)
* @param address - Solana address
*/
async unspent(address) {
if (typeof address !== 'string')
throw new Error(`unspent: wrong address=${address}`);
const [info, blockHash] = await Promise.all([
this.accountInfo(address),
this.recentBlockHash(),
]);
return {
symbol: 'SOL',
decimals: sol.PRECISION,
balance: BigInt(info === undefined ? 0 : info.lamports),
blockhash: blockHash.blockhash,
active: info !== undefined,
};
}
/**
* Returns information about token accounts for address
* @param address - Solana address
* @param tokensInfo - Tokens information (sol.COMMON_TOKENS), Record<mintAddress, TokenInfo>
* @returns
*/
async tokenBalances(address, tokensInfo) {
if (typeof address !== 'string')
throw new Error(`tokenBalance: wrong address=${address}`);
const tokens = await this.jsonCall('getTokenAccountsByOwner', address, {
programId: sol.TOKEN_PROGRAM,
});
if (!Array.isArray(tokens))
throw new Error('sol.unspent: incorrect tokens value');
const res = [];
for (const t of tokens) {
const i = t.account.data.parsed.info;
res.push({
...tokensInfo[i.mint],
contract: i.mint,
decimals: i.tokenAmount.decimals,
balance: BigInt(i.tokenAmount.amount),
tokenAccount: t.pubkey,
});
}
return sortMulti(res, 'contract', 'tokenAccount'); // node returns random order by default
}
async txInfo(signature) {
// json and jsonParsed returns parsed instructions data, it is hard to re-build actual raw tx from it
// base64 doesn't return accountKeys (needed for balances), but we can get it from parsing raw tx
// NOTE: we support only legacy transactions for now (no versioned).
const tx = await this.rpc.call('getTransaction', signature, {
encoding: 'base64',
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
});
const rawBytes = decodeData(tx.transaction);
sol.verifyTx(rawBytes);
const rawTx = sol.TransactionRaw.decode(rawBytes);
const keys = rawTx.msg.data.keys;
const transfers = [];
for (let i = 0; i < keys.length; i++) {
const address = keys[i];
const diff = BigInt(tx.meta.postBalances[i] - tx.meta.preBalances[i]);
if (diff === 0n)
continue;
transfers.push(diff < 0n ? { from: address, value: -diff } : { to: address, value: diff });
}
const tokenBalances = {};
for (const pre of tx.meta.preTokenBalances) {
const { address, ...rest } = mapToken(pre, keys);
tokenBalances[address] = rest;
}
for (const post of tx.meta.postTokenBalances) {
const { address, ...rest } = mapToken(post, keys);
if (!tokenBalances[address])
tokenBalances[address] = rest;
else {
const pre = tokenBalances[address];
// Should not happen
if (pre.contract !== rest.contract)
throw new Error('txInfo: token contract changed');
if (pre.owner !== rest.owner)
throw new Error('txInfo: token owner changed');
if (pre.decimals !== rest.decimals)
throw new Error('txInfo: token decimals changed');
pre.amount = rest.amount - pre.amount;
}
}
const tokenTransfers = [];
for (const tokenAccount in tokenBalances) {
const { amount, ...rest } = tokenBalances[tokenAccount];
if (amount === 0n)
continue;
tokenTransfers.push(amount < 0n
? { from: tokenAccount, value: -amount, ...rest }
: { to: tokenAccount, value: amount, ...rest });
}
return {
hash: signature,
timestamp: tx.blockTime * 1000,
block: tx.slot,
transfers,
tokenTransfers,
reverted: tx.meta.err !== null,
info: {
log: tx.meta.logMessages,
raw: base64.encode(rawBytes),
fee: BigInt(tx.meta.fee),
},
};
}
// Only returns transactions for address, but not for owned accounts (tokens)
async addressTransactions(address, cb, perRequest = 1000) {
let lastTx = undefined;
for (;;) {
const data = await this.rpc.call('getSignaturesForAddress', address, {
encoding: 'jsonParsed',
commitment: 'confirmed',
limit: perRequest,
before: lastTx,
});
if (!data.length)
break;
sortMulti(data, 'slot', 'blockTime');
lastTx = data[0].signature;
for (const { signature } of data)
cb(signature);
}
}
/**
* Returns all transaction information for address.
* @param address - Solana address
*/
async transfers(address, perRequest = 1000) {
if (typeof address !== 'string')
throw new Error(`transfers: wrong address=${address}`);
if (!Number.isSafeInteger(perRequest))
throw new Error(`transfers: wrong perRequest ${perRequest}, expected integer`);
const txPromises = {};
const fetchTx = (signature) => {
if (signature in txPromises)
return;
txPromises[signature] = this.txInfo(signature);
};
const pMain = this.addressTransactions(address, fetchTx, perRequest);
const tokens = await this.jsonCall('getTokenAccountsByOwner', address, {
programId: sol.TOKEN_PROGRAM,
});
await Promise.all([
pMain,
...tokens.map((i) => this.addressTransactions(i.pubkey, fetchTx, perRequest)),
]);
const txs = await Promise.all(Object.values(txPromises));
sortMulti(txs, 'block', 'hash');
return txs;
}
async sendTx(tx) {
return await this.rpc.call('sendTransaction', tx, { encoding: 'base64' });
}
}
/**
* Calculates balances at specific point in time after tx.
* Also, useful as a sanity check in case we've missed something.
* Info from multiple addresses can be merged (sort everything first).
*/
export function calcTransfersDiff(transfers) {
const balances = {};
const tokenBalances = {};
for (const t of transfers) {
for (const it of t.transfers) {
if (it.from) {
if (balances[it.from] === undefined)
balances[it.from] = 0n;
balances[it.from] -= it.value;
}
if (it.to) {
if (balances[it.to] === undefined)
balances[it.to] = 0n;
balances[it.to] += it.value;
}
}
for (const tt of t.tokenTransfers) {
if (!tokenBalances[tt.contract])
tokenBalances[tt.contract] = {};
const token = tokenBalances[tt.contract];
if (tt.from) {
if (token[tt.from] === undefined)
token[tt.from] = 0n;
token[tt.from] -= tt.value;
}
if (tt.to) {
if (token[tt.to] === undefined)
token[tt.to] = 0n;
token[tt.to] += tt.value;
}
}
Object.assign(t, {
balances: { ...balances },
// deep copy
tokenBalances: Object.fromEntries(Object.entries(tokenBalances).map(([k, v]) => [k, { ...v }])),
});
}
return transfers;
}
//# sourceMappingURL=net.js.map