import fs from 'node:fs'; import path from 'node:path'; import { type Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from '@solana/web3.js'; // define some default locations const DEFAULT_KEY_DIR_NAME = '.local_keys'; const DEFAULT_PUBLIC_KEY_FILE = 'keys.json'; const DEFAULT_DEMO_DATA_FILE = 'demo.json'; /* Load locally stored PublicKey addresses TODO: use the helpers library and delete this function */ export function loadPublicKeysFromFile(absPath = `${DEFAULT_KEY_DIR_NAME}/${DEFAULT_PUBLIC_KEY_FILE}`) { try { if (!absPath) throw Error('No path provided'); if (!fs.existsSync(absPath)) throw Error('File does not exist.'); // load the public keys from the file const data = JSON.parse(fs.readFileSync(absPath, { encoding: 'utf-8' })) || {}; // convert all loaded keyed values into valid public keys for (const [key, value] of Object.entries(data)) { data[key] = new PublicKey(value as string) ?? ''; } return data; } catch (err) { console.warn('Unable to load local file'); } // always return an object return {}; } /* Locally save a demo data to the filesystem for later retrieval */ export function saveDemoDataToFile(name: string, newData: any, absPath = `${DEFAULT_KEY_DIR_NAME}/${DEFAULT_DEMO_DATA_FILE}`) { try { let data: object = {}; // fetch all the current values, when the storage file exists if (fs.existsSync(absPath)) data = JSON.parse(fs.readFileSync(absPath, { encoding: 'utf-8' })) || {}; data = { ...data, [name]: newData }; // actually save the data to the file fs.writeFileSync(absPath, JSON.stringify(data), { encoding: 'utf-8', }); return data; } catch (err) { console.warn('Unable to save to file'); console.warn(err); } // always return an object return {}; } /* Locally save a PublicKey addresses to the filesystem for later retrieval */ export function savePublicKeyToFile(name: string, publicKey: PublicKey, absPath = `${DEFAULT_KEY_DIR_NAME}/${DEFAULT_PUBLIC_KEY_FILE}`) { try { // if (!absPath) throw Error("No path provided"); // if (!fs.existsSync(absPath)) throw Error("File does not exist."); // fetch all the current values let data: any = loadPublicKeysFromFile(absPath); // convert all loaded keyed values from PublicKeys to strings for (const [key, value] of Object.entries(data)) { data[key as any] = (value as PublicKey).toBase58(); } data = { ...data, [name]: publicKey.toBase58() }; // actually save the data to the file fs.writeFileSync(absPath, JSON.stringify(data), { encoding: 'utf-8', }); // reload the keys for sanity data = loadPublicKeysFromFile(absPath); return data; } catch (err) { console.warn('Unable to save to file'); } // always return an object return {}; } /* Load a locally stored JSON keypair file and convert it to a valid Keypair */ export function loadKeypairFromFile(absPath: string) { try { if (!absPath) throw Error('No path provided'); if (!fs.existsSync(absPath)) throw Error('File does not exist.'); // load the keypair from the file const keyfileBytes = JSON.parse(fs.readFileSync(absPath, { encoding: 'utf-8' })); // parse the loaded secretKey into a valid keypair const keypair = Keypair.fromSecretKey(new Uint8Array(keyfileBytes)); return keypair; } catch (err) { console.error('loadKeypairFromFile:', err); throw err; } } /* Save a locally stored JSON keypair file for later importing TODO: delete this function and use the helpers library */ export function saveKeypairToFile(keypair: Keypair, relativeFileName: string, dirName: string = DEFAULT_KEY_DIR_NAME) { const fileName = path.join(dirName, `${relativeFileName}.json`); // create the `dirName` directory, if it does not exists if (!fs.existsSync(`./${dirName}/`)) fs.mkdirSync(`./${dirName}/`); // remove the current file, if it already exists if (fs.existsSync(fileName)) fs.unlinkSync(fileName); // write the `secretKey` value as a string fs.writeFileSync(fileName, `[${keypair.secretKey.toString()}]`, { encoding: 'utf-8', }); return fileName; } /* Attempt to load a keypair from the filesystem, or generate and save a new one */ export function loadOrGenerateKeypair(fileName: string, dirName: string = DEFAULT_KEY_DIR_NAME) { try { // compute the path to locate the file const searchPath = path.join(dirName, `${fileName}.json`); let keypair = Keypair.generate(); // attempt to load the keypair from the file if (fs.existsSync(searchPath)) keypair = loadKeypairFromFile(searchPath); // when unable to locate the keypair, save the new one else saveKeypairToFile(keypair, fileName, dirName); return keypair; } catch (err) { console.error('loadOrGenerateKeypair:', err); throw err; } } /* Compute the Solana explorer address for the various data */ export function explorerURL({ address, txSignature, cluster, }: { address?: string; txSignature?: string; cluster?: 'devnet' | 'testnet' | 'mainnet' | 'mainnet-beta'; }) { let baseUrl: string; // if (address) baseUrl = `https://explorer.solana.com/address/${address}`; else if (txSignature) baseUrl = `https://explorer.solana.com/tx/${txSignature}`; else return '[unknown]'; // auto append the desired search params const url = new URL(baseUrl); url.searchParams.append('cluster', cluster || 'devnet'); return `${url.toString()}\n`; } /** * Auto airdrop the given wallet of of a balance of < 0.5 SOL */ export async function airdropOnLowBalance(connection: Connection, keypair: Keypair, forceAirdrop = false) { // get the current balance const balance = await connection.getBalance(keypair.publicKey); // define the low balance threshold before airdrop const MIN_BALANCE_TO_AIRDROP = LAMPORTS_PER_SOL / 2; // current: 0.5 SOL // check the balance of the two accounts, airdrop when low if (forceAirdrop === true || balance < MIN_BALANCE_TO_AIRDROP) { console.log(`Requesting airdrop of 1 SOL to ${keypair.publicKey.toBase58()}...`); await connection.requestAirdrop(keypair.publicKey, LAMPORTS_PER_SOL).then((sig) => { console.log('Tx signature:', sig); // balance = balance + LAMPORTS_PER_SOL; }); // fetch the new balance // const newBalance = await connection.getBalance(keypair.publicKey); // return newBalance; } // else console.log("Balance of:", balance / LAMPORTS_PER_SOL, "SOL"); return balance; } /* Helper function to extract a transaction signature from a failed transaction's error message */ export async function extractSignatureFromFailedTransaction(connection: Connection, err: any, fetchLogs?: boolean) { if (err?.signature) return err.signature; // extract the failed transaction's signature const failedSig = new RegExp(/^((.*)?Error: )?(Transaction|Signature) ([A-Z0-9]{32,}) /gim).exec(err?.message?.toString())?.[4]; // ensure a signature was found if (failedSig) { // when desired, attempt to fetch the program logs from the cluster if (fetchLogs) await connection .getTransaction(failedSig, { maxSupportedTransactionVersion: 0, }) .then((tx) => { console.log(`\n==== Transaction logs for ${failedSig} ====`); console.log(explorerURL({ txSignature: failedSig }), ''); console.log(tx?.meta?.logMessages ?? 'No log messages provided by RPC'); console.log('==== END LOGS ====\n'); }); else { console.log('\n========================================'); console.log(explorerURL({ txSignature: failedSig })); console.log('========================================\n'); } } // always return the failed signature value return failedSig; } /* Standard number formatter */ export function numberFormatter(num: number, forceDecimals = false) { // set the significant figures const minimumFractionDigits = num < 1 || forceDecimals ? 10 : 2; // do the formatting return new Intl.NumberFormat(undefined, { minimumFractionDigits, }).format(num); } /* Display a separator in the console, with our without a message */ export function printConsoleSeparator(message?: string) { console.log('\n==============================================='); console.log('===============================================\n'); if (message) console.log(message); }