UNPKG

micro-sol-signer

Version:

Create, sign & decode Solana transactions with minimum deps

351 lines 12.7 kB
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