Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split into separate files #46

Merged
merged 1 commit into from
Aug 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
661 changes: 7 additions & 654 deletions src/index.ts

Large diffs are not rendered by default.

82 changes: 82 additions & 0 deletions src/lib/airdrop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Connection, Keypair, LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js";
import { InitializeKeypairOptions } from "../types";
import { addKeypairToEnvFile, getKeypairFromEnvironment, getKeypairFromFile } from "./keypair";

const DEFAULT_AIRDROP_AMOUNT = 1 * LAMPORTS_PER_SOL;
const DEFAULT_MINIMUM_BALANCE = 0.5 * LAMPORTS_PER_SOL;
const DEFAULT_ENV_KEYPAIR_VARIABLE_NAME = "PRIVATE_KEY";

export const initializeKeypair = async (
connection: Connection,
options?: InitializeKeypairOptions,
): Promise<Keypair> => {
let {
keypairPath,
envFileName,
envVariableName = DEFAULT_ENV_KEYPAIR_VARIABLE_NAME,
airdropAmount = DEFAULT_AIRDROP_AMOUNT,
minimumBalance = DEFAULT_MINIMUM_BALANCE,
} = options || {};

let keypair: Keypair;

if (keypairPath) {
keypair = await getKeypairFromFile(keypairPath);
} else if (process.env[envVariableName]) {
keypair = getKeypairFromEnvironment(envVariableName);
} else {
keypair = Keypair.generate();
await addKeypairToEnvFile(keypair, envVariableName, envFileName);
}

if (airdropAmount) {
await airdropIfRequired(
connection,
keypair.publicKey,
airdropAmount,
minimumBalance,
);
}

return keypair;
}

// Not exported as we don't want to encourage people to
// request airdrops when they don't need them, ie - don't bother
// the faucet unless you really need to!
const requestAndConfirmAirdrop = async (
connection: Connection,
publicKey: PublicKey,
amount: number,
) => {
const airdropTransactionSignature = await connection.requestAirdrop(
publicKey,
amount,
);
// Wait for airdrop confirmation
const latestBlockHash = await connection.getLatestBlockhash();
await connection.confirmTransaction(
{
blockhash: latestBlockHash.blockhash,
lastValidBlockHeight: latestBlockHash.lastValidBlockHeight,
signature: airdropTransactionSignature,
},
// "finalized" is slow but we must be absolutely sure
// the airdrop has gone through
"finalized",
);
return connection.getBalance(publicKey, "finalized");
};

export const airdropIfRequired = async (
connection: Connection,
publicKey: PublicKey,
airdropAmount: number,
minimumBalance: number,
): Promise<number> => {
const balance = await connection.getBalance(publicKey, "confirmed");
if (balance < minimumBalance) {
return requestAndConfirmAirdrop(connection, publicKey, airdropAmount);
}
return balance;
};
56 changes: 56 additions & 0 deletions src/lib/explorer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Cluster } from "@solana/web3.js";

export const getCustomErrorMessage = (
possibleProgramErrors: Array<string>,
errorMessage: string,
): string | null => {
const customErrorExpression =
/.*custom program error: 0x(?<errorNumber>[0-9abcdef]+)/;

let match = customErrorExpression.exec(errorMessage);
const errorNumberFound = match?.groups?.errorNumber;
if (!errorNumberFound) {
return null;
}
// errorNumberFound is a base16 string
const errorNumber = parseInt(errorNumberFound, 16);
return possibleProgramErrors[errorNumber] || null;
};

const encodeURL = (baseUrl: string, searchParams: Record<string, string>) => {
// This was a little new to me, but it's the
// recommended way to build URLs with query params
// (and also means you don't have to do any encoding)
// https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
const url = new URL(baseUrl);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
};

export const getExplorerLink = (
linkType: "transaction" | "tx" | "address" | "block",
id: string,
cluster: Cluster | "localnet" = "mainnet-beta",
): string => {
const searchParams: Record<string, string> = {};
if (cluster !== "mainnet-beta") {
if (cluster === "localnet") {
// localnet technically isn't a cluster, so requires special handling
searchParams["cluster"] = "custom";
searchParams["customUrl"] = "http://localhost:8899";
} else {
searchParams["cluster"] = cluster;
}
}
let baseUrl: string = "";
if (linkType === "address") {
baseUrl = `https://explorer.solana.com/address/${id}`;
}
if (linkType === "transaction" || linkType === "tx") {
baseUrl = `https://explorer.solana.com/tx/${id}`;
}
if (linkType === "block") {
baseUrl = `https://explorer.solana.com/block/${id}`;
}
return encodeURL(baseUrl, searchParams);
};
102 changes: 102 additions & 0 deletions src/lib/keypair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Keypair } from "@solana/web3.js";
import base58 from "bs58";

// Default value from Solana CLI
const DEFAULT_FILEPATH = "~/.config/solana/id.json";

export const keypairToSecretKeyJSON = (keypair: Keypair): string => {
return JSON.stringify(Array.from(keypair.secretKey));
};

export const getKeypairFromFile = async (filepath?: string) => {
const path = await import("path");
// Work out correct file name
if (!filepath) {
filepath = DEFAULT_FILEPATH;
}
if (filepath[0] === "~") {
const home = process.env.HOME || null;
if (home) {
filepath = path.join(home, filepath.slice(1));
}
}

// Get contents of file
let fileContents: string;
try {
const { readFile } = await import("fs/promises");
const fileContentsBuffer = await readFile(filepath);
fileContents = fileContentsBuffer.toString();
} catch (error) {
throw new Error(`Could not read keypair from file at '${filepath}'`);
}

// Parse contents of file
let parsedFileContents: Uint8Array;
try {
parsedFileContents = Uint8Array.from(JSON.parse(fileContents));
} catch (thrownObject) {
const error = thrownObject as Error;
if (!error.message.includes("Unexpected token")) {
throw error;
}
throw new Error(`Invalid secret key file at '${filepath}'!`);
}
return Keypair.fromSecretKey(parsedFileContents);
};

export const getKeypairFromEnvironment = (variableName: string) => {
const secretKeyString = process.env[variableName];
if (!secretKeyString) {
throw new Error(`Please set '${variableName}' in environment.`);
}

// Try the shorter base58 format first
let decodedSecretKey: Uint8Array;
try {
decodedSecretKey = base58.decode(secretKeyString);
return Keypair.fromSecretKey(decodedSecretKey);
} catch (throwObject) {
const error = throwObject as Error;
if (!error.message.includes("Non-base58 character")) {
throw new Error(
`Invalid secret key in environment variable '${variableName}'!`,
);
}
}

// Try the longer JSON format
try {
decodedSecretKey = Uint8Array.from(JSON.parse(secretKeyString));
} catch (error) {
throw new Error(
`Invalid secret key in environment variable '${variableName}'!`,
);
}
return Keypair.fromSecretKey(decodedSecretKey);
};

export const addKeypairToEnvFile = async (
keypair: Keypair,
variableName: string,
envFileName?: string,
) => {
const { appendFile } = await import("fs/promises");
if (!envFileName) {
envFileName = ".env";
}
const existingSecretKey = process.env[variableName];
if (existingSecretKey) {
throw new Error(`'${variableName}' already exists in env file.`);
}
const secretKeyString = keypairToSecretKeyJSON(keypair);
await appendFile(
envFileName,
`\n# Solana Address: ${keypair.publicKey.toBase58()}\n${variableName}=${secretKeyString}`,
);
};

// Shout out to Dean from WBA for this technique
export const makeKeypairs = (amount: number): Array<Keypair> => {
return Array.from({ length: amount }, () => Keypair.generate());
};
53 changes: 53 additions & 0 deletions src/lib/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Connection, RpcResponseAndContext, SignatureResult, SimulatedTransactionResponse } from "@solana/web3.js";
import { confirmTransaction } from "./transaction";

export const getLogs = async (
connection: Connection,
tx: string,
): Promise<Array<string>> => {
await confirmTransaction(connection, tx);
const txDetails = await connection.getTransaction(tx, {
maxSupportedTransactionVersion: 0,
commitment: "confirmed",
});
return txDetails?.meta?.logMessages || [];
};

export const getErrorFromRPCResponse = (
rpcResponse: RpcResponseAndContext<
SignatureResult | SimulatedTransactionResponse
>,
) => {
// Note: `confirmTransaction` does not throw an error if the confirmation does not succeed,
// but rather a `TransactionError` object. so we handle that here
// See https://solana-labs.github.io/solana-web3.js/classes/Connection.html#confirmTransaction.confirmTransaction-1

const error = rpcResponse.value.err;
if (error) {
// Can be a string or an object (literally just {}, no further typing is provided by the library)
// https://github.com/solana-labs/solana-web3.js/blob/4436ba5189548fc3444a9f6efb51098272926945/packages/library-legacy/src/connection.ts#L2930
// TODO: if still occurs in web3.js 2 (unlikely), fix it.
if (typeof error === "object") {
const errorKeys = Object.keys(error);
if (errorKeys.length === 1) {
if (errorKeys[0] !== "InstructionError") {
throw new Error(`Unknown RPC error: ${error}`);
}
// @ts-ignore due to missing typing information mentioned above.
const instructionError = error["InstructionError"];
// An instruction error is a custom program error and looks like:
// [
// 1,
// {
// "Custom": 1
// }
// ]
// See also https://solana.stackexchange.com/a/931/294
throw new Error(
`Error in transaction: instruction index ${instructionError[0]}, custom program error ${instructionError[1]["Custom"]}`,
);
}
}
throw Error(error.toString());
}
};
Loading
Loading