diff --git a/examples/helper-lib/LICENSE b/examples/helper-lib/LICENSE new file mode 100644 index 000000000..ec09953d3 --- /dev/null +++ b/examples/helper-lib/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2023 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/examples/helper-lib/package.json b/examples/helper-lib/package.json new file mode 100644 index 000000000..9a141d58c --- /dev/null +++ b/examples/helper-lib/package.json @@ -0,0 +1,25 @@ +{ + "name": "@solana/example-helper-lib", + "private": true, + "type": "module", + "scripts": { + "prestart": "turbo --output-logs=errors-only compile:js compile:typedefs", + "run:example": "tsx src/example.ts", + "start": "start-server-and-test '../../scripts/start-shared-test-validator.sh' http://127.0.0.1:8899/health run:example", + "style:fix": "pnpm eslint --fix src && pnpm prettier --log-level warn --ignore-unknown --write ./*", + "test:lint": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-lint.config.ts --rootDir . --silent --testMatch 'src/**/*.{ts,tsx}'", + "test:prettier": "TERM_OVERRIDE=\"${TURBO_HASH:+dumb}\" TERM=${TERM_OVERRIDE:-$TERM} jest -c ../../node_modules/@solana/test-config/jest-prettier.config.ts --rootDir . --silent", + "test:typecheck": "tsc" + }, + "dependencies": { + "@solana-program/compute-budget": "^0.11.0", + "@solana-program/system": "^0.9.0", + "@solana-program/token": "^0.8.0", + "@solana/example-utils": "workspace:*", + "@solana/kit": "workspace:*" + }, + "devDependencies": { + "start-server-and-test": "^2.1.2", + "tsx": "^4.20.6" + } +} \ No newline at end of file diff --git a/examples/helper-lib/src/example.ts b/examples/helper-lib/src/example.ts new file mode 100644 index 000000000..e80804826 --- /dev/null +++ b/examples/helper-lib/src/example.ts @@ -0,0 +1,434 @@ +/** + * EXAMPLE + * Building a helper lib client using @solana/kit and @solana-program libraries + * + * Before running any of the examples in this monorepo, make sure to set up a test validator by + * running `pnpm test:live-with-test-validator:setup` in the root directory. + * + * To run this example, execute `pnpm start` in this directory. + */ +import { createLogger } from '@solana/example-utils/createLogger.js'; +import pressAnyKeyPrompt from '@solana/example-utils/pressAnyKeyPrompt.js'; +import { + address, + Address, + airdropFactory, + appendTransactionMessageInstruction, + appendTransactionMessageInstructions, + assertIsTransactionWithBlockhashLifetime, + BlockhashLifetimeConstraint, + ClusterUrl, + createSolanaRpc, + createSolanaRpcSubscriptions, + createTransactionMessage, + createTransactionPlanExecutor, + createTransactionPlanner, + devnet, + generateKeyPairSigner, + getSignatureFromTransaction, + Instruction, + InstructionPlan, + lamports, + Lamports, + mainnet, + MainnetUrl, + parallelInstructionPlan, + pipe, + Rpc, + RpcSubscriptions, + sendAndConfirmTransactionFactory, + sequentialInstructionPlan, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + Signature, + signTransactionMessageWithSigners, + singleTransactionPlan, + SolanaError, + SolanaRpcApi, + SolanaRpcApiFromClusterUrl, + SolanaRpcSubscriptionsApi, + TransactionMessage, + TransactionMessageWithBlockhashLifetime, + TransactionMessageWithFeePayer, + TransactionPartialSigner, + TransactionPlan, + TransactionPlanResult, + TransactionPlanResultContext, +} from '@solana/kit'; +import { getCreateAccountInstruction, getTransferSolInstruction } from '@solana-program/system'; +import { estimateAndUpdateProvisoryComputeUnitLimitFactory, estimateComputeUnitLimitFactory, fillProvisorySetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction } from '@solana-program/compute-budget'; +import { findAssociatedTokenPda, getCreateAssociatedTokenInstruction, getInitializeMint2Instruction, getMintSize, getMintToCheckedInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; + +const log = createLogger('Helper Lib'); + +type TransactionConfig = { + feePayer: TransactionPartialSigner; + blockhash?: BlockhashLifetimeConstraint; + cuPrice?: number | bigint; +} + +type SendableTransactionMessage = TransactionMessage & TransactionMessageWithFeePayer & TransactionMessageWithBlockhashLifetime; + +type TransactionBuilder = { + instruction(instruction: Instruction): Promise; + instructions(instructions: Instruction[]): Promise; +} + +type TransactionPlanBuilder = { + instructionPlan(plan: InstructionPlan): Promise; +} + +/** + * A compact summary of a {@link SingleTransactionPlanResult}. + */ +export type CompactSingleTransactionSummary< + TContext extends TransactionPlanResultContext = TransactionPlanResultContext, +> = + | Readonly<{ + context?: TContext; + signature: Signature; + status: 'successful'; + }> + | Readonly<{ + error: SolanaError; + status: 'failed'; + }> + | Readonly<{ + status: 'canceled'; + }>; + +/** + * Summarizes a {@link TransactionPlanResult} into a flat array of compact single transaction summaries. + * @param result The transaction plan result to summarize + * @returns An array of compact single transaction summaries + */ +function summarizeTransactionPlanResult(result: TransactionPlanResult): CompactSingleTransactionSummary[] { + const transactionResults: CompactSingleTransactionSummary[] = []; + + function traverse(result: TransactionPlanResult) { + if (result.kind === 'single') { + if (result.status.kind === 'successful') { + const signature = getSignatureFromTransaction(result.status.transaction); + transactionResults.push({ context: result.status.context, signature, status: 'successful' }); + } else if (result.status.kind === 'failed') { + transactionResults.push({ error: result.status.error, status: 'failed' }); + } else if (result.status.kind === 'canceled') { + transactionResults.push({ status: 'canceled' }); + } + } else { + for (const subResult of result.plans) { + traverse(subResult); + } + } + } + + traverse(result); + return transactionResults; +} + +type HideFromMainnet = TClusterUrl extends MainnetUrl ? never : T; + +type SolanaClient< + TClusterUrl extends ClusterUrl, + TRpcApi extends SolanaRpcApiFromClusterUrl = SolanaRpcApiFromClusterUrl, + TRpcSubscriptionsApi extends SolanaRpcSubscriptionsApi = SolanaRpcSubscriptionsApi, +> = { + rpc: Rpc; + rpcSubscriptions: RpcSubscriptions; + airdrop: HideFromMainnet Promise>; + transaction(config: TransactionConfig): TransactionBuilder; + transactionPlan(config: TransactionConfig): TransactionPlanBuilder; + sendAndConfirm(transaction: SendableTransactionMessage | TransactionPlan, abortSignal?: AbortSignal): Promise; +} + +function createSolanaClient( + rpcEndpoint: TClusterUrl, + rpcSubscriptionsEndpoint?: TClusterUrl +): SolanaClient { + const rpc = createSolanaRpc(rpcEndpoint); + // Typescript doesn't know the cluster URL, so internally we cast to the base SolanaRpcApi + // We return `rpc` which is cluster-aware though + const internalRpc = rpc as Rpc; + + const rpcSubscriptionsEndpointOrDefault = rpcSubscriptionsEndpoint + ? rpcSubscriptionsEndpoint + : (rpcEndpoint.replace('http', 'ws').replace('8899', '8900') as TClusterUrl); + const rpcSubscriptions = createSolanaRpcSubscriptions(rpcSubscriptionsEndpointOrDefault); + const internalRpcSubscriptions = rpcSubscriptions as RpcSubscriptions; + + // We hide this from mainnet in the `SolanaClient` type + const airdrop = airdropFactory({ rpc: internalRpc, rpcSubscriptions: internalRpcSubscriptions }); + + async function createBaseTransactionMessage(config: TransactionConfig, abortSignal?: AbortSignal): Promise { + return pipe( + createTransactionMessage({ version: 0 }), + tx => setTransactionMessageFeePayer(config.feePayer.address, tx), + tx => config.cuPrice ? appendTransactionMessageInstruction( + getSetComputeUnitPriceInstruction({ + microLamports: config.cuPrice, + }), + tx + ) : tx, + tx => fillProvisorySetComputeUnitLimitInstruction(tx), + async tx => { + if (config.blockhash) { + return setTransactionMessageLifetimeUsingBlockhash(config.blockhash, tx); + } else { + const { value: latestBlockhash } = await internalRpc.getLatestBlockhash({ commitment: 'confirmed' }).send({ abortSignal }); + return setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx); + } + } + ) + } + + function transactionBuilder(config: TransactionConfig): TransactionBuilder { + return { + async instruction(instruction: Instruction, abortSignal?: AbortSignal): Promise { + const baseMessage = await createBaseTransactionMessage(config, abortSignal); + return pipe( + baseMessage, + tx => appendTransactionMessageInstruction(instruction, tx) + ) + }, + async instructions(instructions: Instruction[], abortSignal?: AbortSignal): Promise { + const baseMessage = await createBaseTransactionMessage(config, abortSignal); + return pipe( + baseMessage, + tx => appendTransactionMessageInstructions(instructions, tx) + ) + } + } + } + + function transactionPlanBuilder(config: TransactionConfig): TransactionPlanBuilder { + const transactionPlanner = createTransactionPlanner({ + async createTransactionMessage(innerConfig) { + const abortSignal = innerConfig ? innerConfig.abortSignal : undefined; + return await createBaseTransactionMessage(config, abortSignal); + } + }) + + return { + async instructionPlan(plan: InstructionPlan, abortSignal?: AbortSignal): Promise { + return await transactionPlanner(plan, { abortSignal }); + } + } + } + + const sendAndConfirm = sendAndConfirmTransactionFactory({ rpc: internalRpc, rpcSubscriptions: internalRpcSubscriptions }); + const estimateCULimit = estimateComputeUnitLimitFactory({ rpc: internalRpc }); + async function estimateWithMultiplier(...args: Parameters): Promise { + const estimate = await estimateCULimit(...args); + return Math.ceil(estimate * 1.1); + } + const estimateAndSetCULimit = estimateAndUpdateProvisoryComputeUnitLimitFactory(estimateWithMultiplier); + + const transactionExecutor = createTransactionPlanExecutor({ + async executeTransactionMessage(message, config) { + const abortSignal = config ? config.abortSignal : undefined; + const messageWithCUEstimate = await estimateAndSetCULimit(message, { abortSignal }); + const signedTransaction = await signTransactionMessageWithSigners(messageWithCUEstimate, { abortSignal }); + assertIsTransactionWithBlockhashLifetime(signedTransaction); + await sendAndConfirm(signedTransaction, { commitment: 'confirmed', abortSignal }); + return { transaction: signedTransaction }; + } + }) + + async function airdropHelper(recipientAddress: Address, amount: number | bigint | Lamports): Promise { + const lamportsAmount = typeof amount === 'number' ? lamports(BigInt(amount)) : typeof amount === 'bigint' ? lamports(amount) : amount; + return await airdrop({ + commitment: 'confirmed', + recipientAddress, + lamports: lamportsAmount, + }); + } + + return { + rpc, + rpcSubscriptions, + airdrop: airdropHelper as HideFromMainnet, + transaction(config: TransactionConfig): TransactionBuilder { + return transactionBuilder(config); + }, + transactionPlan(config: TransactionConfig): TransactionPlanBuilder { + return transactionPlanBuilder(config); + }, + async sendAndConfirm(transaction: TransactionMessage & TransactionMessageWithFeePayer | TransactionPlan, abortSignal?: AbortSignal): Promise { + let transactionPlan: TransactionPlan; + if ('kind' in transaction) { + transactionPlan = transaction; + } else { + transactionPlan = singleTransactionPlan(transaction); + } + const planResult = await transactionExecutor(transactionPlan, { abortSignal }); + return summarizeTransactionPlanResult(planResult); + } + } +} + +function sol(amount: number): Lamports { + return lamports(BigInt(Math.ceil(amount * 1_000_000_000))); +} + +function displayAmount(amount: bigint, decimals: number): string { + return new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: decimals, + // @ts-expect-error TS doesn't know you can do this yet + }).format(`${amount}E-${decimals}`); +} + +const client = createSolanaClient('http://127.0.0.1:8899', 'ws://127.0.0.1:8900'); + +// Example: Airdrop SOL to a new signer +const signer = await generateKeyPairSigner(); +await client.airdrop(signer.address, sol(1.5)); +log.info('Airdropped 1.5 SOL to the new signer address'); + +const { value: balance } = await client.rpc.getBalance(signer.address).send(); +log.info({ address: signer.address, balance: `${displayAmount(balance, 9)} SOL` }, 'New balance for signer account'); +await pressAnyKeyPrompt('Press any key to continue'); + +// Example: Transfer lamports to a new account +async function transferExample() { + const destination = await generateKeyPairSigner(); + const transaction = await client.transaction({ feePayer: signer }).instruction( + getTransferSolInstruction({ + source: signer, + destination: destination.address, + amount: sol(0.001), + }) + ) + const [result] = await client.sendAndConfirm(transaction); + if (result.status === 'successful') { + const signature = result.signature; + log.info({ signature }, 'Transfer transaction confirmed'); + } else { + log.error({ result }, 'Transfer transaction failed'); + } + await pressAnyKeyPrompt('Press any key to continue'); +} +await transferExample(); + +// Example: Create a new mint +async function createMintExample() { + const tokenMint = await generateKeyPairSigner(); + + const mintSize = getMintSize(); + const lamportsForMintAccount = await client.rpc.getMinimumBalanceForRentExemption(BigInt(mintSize)).send(); + + const transaction = await client.transaction({ feePayer: signer }).instructions([ + getCreateAccountInstruction({ + payer: signer, + newAccount: tokenMint, + lamports: lamportsForMintAccount, + space: mintSize, + programAddress: TOKEN_PROGRAM_ADDRESS, + }), + getInitializeMint2Instruction({ + mint: tokenMint.address, + decimals: 6, + mintAuthority: signer.address, + }) + ]) + const [result] = await client.sendAndConfirm(transaction); + if (result.status === 'successful') { + const signature = result.signature; + log.info({ signature, mintAddress: tokenMint.address }, 'Mint creation transaction confirmed'); + } else { + log.error({ result }, 'Transfer transaction failed'); + } + await pressAnyKeyPrompt('Press any key to continue'); +} +await createMintExample(); + +// Example: Airdrop tokens to multiple recipients +async function tokenAirdropExample() { + const tokenMint = await generateKeyPairSigner(); + + const mintSize = getMintSize(); + const lamportsForMintAccount = await client.rpc.getMinimumBalanceForRentExemption(BigInt(mintSize)).send(); + + const destinationAddresses = await Promise.all( + Array.from({ length: 100 }, async () => { + const signer = await generateKeyPairSigner(); + return signer.address; + }), + ); + + const destinationTokenAccountAddresses = await Promise.all(destinationAddresses.map(async ownerAddress => { + const [address] = await findAssociatedTokenPda({ + owner: ownerAddress, + mint: tokenMint.address, + tokenProgram: TOKEN_PROGRAM_ADDRESS + }); + return address; + })); + + const instructionPlan = sequentialInstructionPlan([ + getCreateAccountInstruction({ + payer: signer, + newAccount: tokenMint, + lamports: lamportsForMintAccount, + space: mintSize, + programAddress: TOKEN_PROGRAM_ADDRESS, + }), + getInitializeMint2Instruction({ + mint: tokenMint.address, + decimals: 6, + mintAuthority: signer.address, + }), + parallelInstructionPlan(destinationAddresses.map((address, index) => + sequentialInstructionPlan([ + // create the associated token account + getCreateAssociatedTokenInstruction({ + payer: signer, + ata: destinationTokenAccountAddresses[index], + owner: address, + mint: tokenMint.address, + }), + // mint to this token account + getMintToCheckedInstruction({ + mint: tokenMint.address, + token: destinationTokenAccountAddresses[index], + mintAuthority: signer, + amount: 1_000 * (10 ** 6), // 1,000 tokens each, considering 6 decimals + decimals: 6, + }) + ]) + )) + ]) + + const { value: blockhash } = await client.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send(); + const transactionPlan = await client.transactionPlan({ feePayer: signer, blockhash }).instructionPlan(instructionPlan); + const result = await client.sendAndConfirm(transactionPlan); + if (result.every(r => r.status === 'successful')) { + const signatures = result.map(r => r.signature); + log.info({ signatures, mintAddress: tokenMint.address }, 'Token airdrop transactions confirmed for multiple recipients'); + } else { + log.error({ result }, 'Token airdrop failed'); + } + await pressAnyKeyPrompt('Press any key to quit'); +} +await tokenAirdropExample(); + +// Additional typetests +async () => { + const client = createSolanaClient(''); + await client.rpc.requestAirdrop(address('a'), lamports(1n)).send(); + await client.airdrop(address('a'), 1); + sendAndConfirmTransactionFactory({ rpc: client.rpc, rpcSubscriptions: client.rpcSubscriptions }); + + const devnetClient = createSolanaClient(devnet('')); + await devnetClient.rpc.requestAirdrop(address('a'), lamports(1n)).send(); + await devnetClient.airdrop(address('a'), 1); + sendAndConfirmTransactionFactory({ rpc: devnetClient.rpc, rpcSubscriptions: devnetClient.rpcSubscriptions }); + + const mainnetClient = createSolanaClient(mainnet('')); + await mainnetClient.rpc.getBalance(address('a')).send(); + // @ts-expect-error requestAirdrop should be unavailable on mainnet rpc + await mainnetClient.rpc.requestAirdrop(address('a'), lamports(1n)).send(); + // @ts-expect-error airdrop should be unavailable on mainnet client + await mainnetClient.airdrop(address('a'), 1); // should error + sendAndConfirmTransactionFactory({ rpc: mainnetClient.rpc, rpcSubscriptions: mainnetClient.rpcSubscriptions }); +} diff --git a/examples/helper-lib/tsconfig.json b/examples/helper-lib/tsconfig.json new file mode 100644 index 000000000..fe0c3589d --- /dev/null +++ b/examples/helper-lib/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "target": "ESNext" + }, + "display": "@solana/example-transfer-lamports", + "extends": "../../packages/tsconfig/base.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2e20bc9c..48900a3aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,31 @@ importers: specifier: ^4.20.6 version: 4.20.6 + examples/helper-lib: + dependencies: + '@solana-program/compute-budget': + specifier: ^0.11.0 + version: 0.11.0(@solana/kit@packages+kit) + '@solana-program/system': + specifier: ^0.9.0 + version: 0.9.0(@solana/kit@packages+kit) + '@solana-program/token': + specifier: ^0.8.0 + version: 0.8.1(@solana/kit@packages+kit) + '@solana/example-utils': + specifier: workspace:* + version: link:../utils + '@solana/kit': + specifier: workspace:* + version: link:../../packages/kit + devDependencies: + start-server-and-test: + specifier: ^2.1.2 + version: 2.1.2 + tsx: + specifier: ^4.20.6 + version: 4.20.6 + examples/react-app: dependencies: '@radix-ui/react-dropdown-menu': @@ -4312,6 +4337,11 @@ packages: peerDependencies: '@solana/kit': ^5.0 + '@solana-program/token@0.8.1': + resolution: {integrity: sha512-Pj34SchWTbDKMODVzzphYvBQ5JpyRw1ObwOsw170L8Rz0jPsj3z7UGf5fcUAL+K1/t3G+UaBrlvAyWeegYzTFQ==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana-program/token@0.9.0': resolution: {integrity: sha512-vnZxndd4ED4Fc56sw93cWZ2djEeeOFxtaPS8SPf5+a+JZjKA/EnKqzbE1y04FuMhIVrLERQ8uR8H2h72eZzlsA==} peerDependencies: @@ -11322,6 +11352,10 @@ snapshots: dependencies: '@solana/kit': link:packages/kit + '@solana-program/token@0.8.1(@solana/kit@packages+kit)': + dependencies: + '@solana/kit': link:packages/kit + '@solana-program/token@0.9.0(@solana/kit@packages+kit)': dependencies: '@solana/kit': link:packages/kit