diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 24bc305f6..f98eb9a20 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,7 +34,7 @@ jobs: - name: Install dependencies run: yarn install - name: Run tests - run: yarn test --exclude legacy-sdk/whirlpool --output-style static + run: yarn test --exclude legacy-sdk/integration --output-style static lint: runs-on: ubuntu-latest diff --git a/Anchor.toml b/Anchor.toml index 4575a0159..65afcac34 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -9,7 +9,7 @@ cluster = "localnet" wallet = "~/.config/solana/id.json" [scripts] -test = "yarn vitest run --test-timeout 1000000 --no-file-parallelism --globals legacy-sdk/whirlpool/tests" +test = "yarn vitest run --test-timeout 1000000 --no-file-parallelism --globals legacy-sdk" [test.validator] slots_per_epoch = "33" diff --git a/legacy-sdk/cli/package.json b/legacy-sdk/cli/package.json index f706521af..3bd53716e 100644 --- a/legacy-sdk/cli/package.json +++ b/legacy-sdk/cli/package.json @@ -9,8 +9,7 @@ }, "dependencies": { "@coral-xyz/anchor": "0.29.0", - "@orca-so/common-sdk": "0.6.4", - "@orca-so/orca-sdk": "0.2.0", + "@orca-so/common-sdk": "*", "@orca-so/whirlpools-sdk": "*", "@solana/spl-token": "0.4.1", "@solana/web3.js": "^1.90.0", diff --git a/legacy-sdk/common/README.md b/legacy-sdk/common/README.md new file mode 100644 index 000000000..ae45e4e1f --- /dev/null +++ b/legacy-sdk/common/README.md @@ -0,0 +1,2 @@ +# Orca Common SDK +This package contains a set of utility functions used by other Typescript components in Orca. diff --git a/legacy-sdk/common/package.json b/legacy-sdk/common/package.json new file mode 100644 index 000000000..a9529baf7 --- /dev/null +++ b/legacy-sdk/common/package.json @@ -0,0 +1,33 @@ +{ + "name": "@orca-so/common-sdk", + "version": "0.6.5", + "description": "Common Typescript components across Orca", + "repository": "https://github.com/orca-so/orca-sdks", + "author": "Orca Foundation", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "peerDependencies": { + "@solana/spl-token": "^0.4.1", + "@solana/web3.js": "^1.90.0", + "decimal.js": "^10.4.3" + }, + "dependencies": { + "tiny-invariant": "^1.3.1" + }, + "devDependencies": { + "@solana/spl-token": "^0.4.1", + "@solana/web3.js": "^1.90.0", + "decimal.js": "^10.4.3", + "typescript": "^5.6.3" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf dist", + "prepublishOnly": "cd ../.. && yarn build legacy-sdk/common" + }, + "files": [ + "/dist", + "README.md" + ] +} diff --git a/legacy-sdk/common/src/index.ts b/legacy-sdk/common/src/index.ts new file mode 100644 index 000000000..3e948bdff --- /dev/null +++ b/legacy-sdk/common/src/index.ts @@ -0,0 +1,2 @@ +export * from "./math"; +export * from "./web3"; diff --git a/legacy-sdk/common/src/math/decimal-util.ts b/legacy-sdk/common/src/math/decimal-util.ts new file mode 100644 index 000000000..41c96bc8c --- /dev/null +++ b/legacy-sdk/common/src/math/decimal-util.ts @@ -0,0 +1,28 @@ +import BN from "bn.js"; +import Decimal from "decimal.js"; + +export class DecimalUtil { + public static adjustDecimals(input: Decimal, shift = 0): Decimal { + return input.div(Decimal.pow(10, shift)); + } + + public static fromBN(input: BN, shift = 0): Decimal { + return new Decimal(input.toString()).div(new Decimal(10).pow(shift)); + } + + public static fromNumber(input: number, shift = 0): Decimal { + return new Decimal(input).div(new Decimal(10).pow(shift)); + } + + public static toBN(input: Decimal, shift = 0): BN { + if (input.isNeg()) { + throw new Error( + "Negative decimal value ${input} cannot be converted to BN.", + ); + } + + const shiftedValue = input.mul(new Decimal(10).pow(shift)); + const zeroDecimalValue = shiftedValue.trunc(); + return new BN(zeroDecimalValue.toString()); + } +} diff --git a/legacy-sdk/common/src/math/index.ts b/legacy-sdk/common/src/math/index.ts new file mode 100644 index 000000000..8f0c21a85 --- /dev/null +++ b/legacy-sdk/common/src/math/index.ts @@ -0,0 +1,3 @@ +export * from "./decimal-util"; +export * from "./math-util"; +export * from "./percentage"; diff --git a/legacy-sdk/common/src/math/math-util.ts b/legacy-sdk/common/src/math/math-util.ts new file mode 100644 index 000000000..24997d8e9 --- /dev/null +++ b/legacy-sdk/common/src/math/math-util.ts @@ -0,0 +1,79 @@ +import BN from "bn.js"; +import Decimal from "decimal.js"; + +/** + * @category Math + */ +export const ZERO = new BN(0); + +/** + * @category Math + */ +export const ONE = new BN(1); + +/** + * @category Math + */ +export const TWO = new BN(2); + +/** + * @category Math + */ +export const U128 = TWO.pow(new BN(128)); + +/** + * @category Math + */ +export const U64_MAX = TWO.pow(new BN(64)).sub(ONE); + +/** + * @category Math + */ +export class MathUtil { + public static toX64_BN(num: BN): BN { + return num.mul(new BN(2).pow(new BN(64))); + } + + public static toX64_Decimal(num: Decimal): Decimal { + return num.mul(Decimal.pow(2, 64)); + } + + public static toX64(num: Decimal): BN { + return new BN(num.mul(Decimal.pow(2, 64)).floor().toFixed()); + } + + public static fromX64(num: BN): Decimal { + return new Decimal(num.toString()).mul(Decimal.pow(2, -64)); + } + + public static fromX64_Decimal(num: Decimal): Decimal { + return num.mul(Decimal.pow(2, -64)); + } + + public static fromX64_BN(num: BN): BN { + return num.div(new BN(2).pow(new BN(64))); + } + + public static shiftRightRoundUp(n: BN): BN { + let result = n.shrn(64); + + if (n.mod(U64_MAX).gt(ZERO)) { + result = result.add(ONE); + } + + return result; + } + + public static divRoundUp(n0: BN, n1: BN): BN { + const hasRemainder = !n0.mod(n1).eq(ZERO); + if (hasRemainder) { + return n0.div(n1).add(new BN(1)); + } else { + return n0.div(n1); + } + } + + public static subUnderflowU128(n0: BN, n1: BN): BN { + return n0.add(U128).sub(n1).mod(U128); + } +} diff --git a/legacy-sdk/common/src/math/percentage.ts b/legacy-sdk/common/src/math/percentage.ts new file mode 100644 index 000000000..ddf635e02 --- /dev/null +++ b/legacy-sdk/common/src/math/percentage.ts @@ -0,0 +1,63 @@ +import BN from "bn.js"; +import Decimal from "decimal.js"; + +/** + * @category Math + */ +export class Percentage { + readonly numerator: BN; + readonly denominator: BN; + + constructor(numerator: BN, denominator: BN) { + this.numerator = numerator; + this.denominator = denominator; + } + + public static fromDecimal(number: Decimal): Percentage { + return Percentage.fromFraction(number.mul(100000).toNumber(), 10000000); + } + + public static fromFraction( + numerator: BN | number, + denominator: BN | number, + ): Percentage { + const num = + typeof numerator === "number" ? new BN(numerator.toString()) : numerator; + const denom = + typeof denominator === "number" + ? new BN(denominator.toString()) + : denominator; + return new Percentage(num, denom); + } + + public toString = (): string => { + return `${this.numerator.toString()}/${this.denominator.toString()}`; + }; + + public toDecimal() { + if (this.denominator.eq(new BN(0))) { + return new Decimal(0); + } + return new Decimal(this.numerator.toString()).div( + new Decimal(this.denominator.toString()), + ); + } + + public add(p2: Percentage): Percentage { + const denomGcd = this.denominator.gcd(p2.denominator); + const denomLcm = this.denominator.div(denomGcd).mul(p2.denominator); + + const p1DenomAdjustment = denomLcm.div(this.denominator); + const p2DenomAdjustment = denomLcm.div(p2.denominator); + + const p1NumeratorAdjusted = this.numerator.mul(p1DenomAdjustment); + const p2NumeratorAdjusted = p2.numerator.mul(p2DenomAdjustment); + + const newNumerator = p1NumeratorAdjusted.add(p2NumeratorAdjusted); + + return new Percentage( + new BN(newNumerator.toString()), + new BN(denomLcm.toString()), + ); + } +} diff --git a/legacy-sdk/common/src/web3/address-util.ts b/legacy-sdk/common/src/web3/address-util.ts new file mode 100644 index 000000000..e65f2a5a7 --- /dev/null +++ b/legacy-sdk/common/src/web3/address-util.ts @@ -0,0 +1,43 @@ +import { PublicKey } from "@solana/web3.js"; + +export declare type Address = PublicKey | string; + +/** + * @category Util + */ +export type PDA = { publicKey: PublicKey; bump: number }; + +/** + * @category Util + */ +export class AddressUtil { + public static toPubKey(address: Address): PublicKey { + return address instanceof PublicKey ? address : new PublicKey(address); + } + + public static toPubKeys(addresses: Address[]): PublicKey[] { + return addresses.map((address) => AddressUtil.toPubKey(address)); + } + + public static toString(address: Address): string { + if (typeof address === "string") { + return address; + } + return AddressUtil.toPubKey(address).toBase58(); + } + + public static toStrings(addresses: Address[]): string[] { + return addresses.map((address) => AddressUtil.toString(address)); + } + + public static findProgramAddress( + seeds: (Uint8Array | Buffer)[], + programId: PublicKey, + ): PDA { + const [publicKey, bump] = PublicKey.findProgramAddressSync( + seeds, + programId, + ); + return { publicKey, bump }; + } +} diff --git a/legacy-sdk/common/src/web3/ata-util.ts b/legacy-sdk/common/src/web3/ata-util.ts new file mode 100644 index 000000000..2bbe62606 --- /dev/null +++ b/legacy-sdk/common/src/web3/ata-util.ts @@ -0,0 +1,197 @@ +import { + NATIVE_MINT, + NATIVE_MINT_2022, + createAssociatedTokenAccountIdempotentInstruction, + createAssociatedTokenAccountInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import type { Connection, PublicKey } from "@solana/web3.js"; +import type BN from "bn.js"; +import { ZERO } from "../math"; +import { + ParsableMintInfo, + ParsableTokenAccountInfo, + getMultipleParsedAccounts, +} from "./network"; +import type { + ResolvedTokenAddressInstruction, + WrappedSolAccountCreateMethod, +} from "./token-util"; +import { TokenUtil } from "./token-util"; +import { EMPTY_INSTRUCTION } from "./transactions/types"; + +/** + * IMPORTANT: wrappedSolAmountIn should only be used for input/source token that + * could be SOL. This is because when SOL is the output, it is the end + * destination, and thus does not need to be wrapped with an amount. + * + * @param connection Solana connection class + * @param ownerAddress The user's public key + * @param tokenMint Token mint address + * @param wrappedSolAmountIn Optional. Only use for input/source token that could be SOL + * @param payer Payer that would pay the rent for the creation of the ATAs + * @param modeIdempotent Optional. Use CreateIdempotent instruction instead of Create instruction + * @param allowPDAOwnerAddress Optional. Allow PDA to be used as the ATA owner address + * @param wrappedSolAccountCreateMethod - Optional. How to create the temporary WSOL account. + * @returns + */ +export async function resolveOrCreateATA( + connection: Connection, + ownerAddress: PublicKey, + tokenMint: PublicKey, + getAccountRentExempt: () => Promise, + wrappedSolAmountIn = ZERO, + payer = ownerAddress, + modeIdempotent: boolean = false, + allowPDAOwnerAddress: boolean = false, + wrappedSolAccountCreateMethod: WrappedSolAccountCreateMethod = "keypair", +): Promise { + const instructions = await resolveOrCreateATAs( + connection, + ownerAddress, + [{ tokenMint, wrappedSolAmountIn }], + getAccountRentExempt, + payer, + modeIdempotent, + allowPDAOwnerAddress, + wrappedSolAccountCreateMethod, + ); + return instructions[0]!; +} + +type ResolvedTokenAddressRequest = { + tokenMint: PublicKey; + wrappedSolAmountIn?: BN; +}; + +/** + * IMPORTANT: wrappedSolAmountIn should only be used for input/source token that + * could be SOL. This is because when SOL is the output, it is the end + * destination, and thus does not need to be wrapped with an amount. + * + * @param connection Solana connection class + * @param ownerAddress The user's public key + * @param tokenMint Token mint address + * @param wrappedSolAmountIn Optional. Only use for input/source token that could be SOL + * @param payer Payer that would pay the rent for the creation of the ATAs + * @param modeIdempotent Optional. Use CreateIdempotent instruction instead of Create instruction + * @param allowPDAOwnerAddress Optional. Allow PDA to be used as the ATA owner address + * @param wrappedSolAccountCreateMethod - Optional. How to create the temporary WSOL account. + * @returns + */ +export async function resolveOrCreateATAs( + connection: Connection, + ownerAddress: PublicKey, + requests: ResolvedTokenAddressRequest[], + getAccountRentExempt: () => Promise, + payer = ownerAddress, + modeIdempotent: boolean = false, + allowPDAOwnerAddress: boolean = false, + wrappedSolAccountCreateMethod: WrappedSolAccountCreateMethod = "keypair", +): Promise { + const nonNativeMints = requests.filter( + ({ tokenMint }) => !tokenMint.equals(NATIVE_MINT), + ); + const nativeMints = requests.filter(({ tokenMint }) => + tokenMint.equals(NATIVE_MINT), + ); + const nativeMint2022 = requests.filter(({ tokenMint }) => + tokenMint.equals(NATIVE_MINT_2022), + ); + + if (nativeMints.length > 1) { + throw new Error("Cannot resolve multiple WSolAccounts"); + } + + if (nativeMint2022.length > 0) { + throw new Error("NATIVE_MINT_2022 is not supported"); + } + + let instructionMap: { [tokenMint: string]: ResolvedTokenAddressInstruction } = + {}; + if (nonNativeMints.length > 0) { + const mints = await getMultipleParsedAccounts( + connection, + nonNativeMints.map((a) => a.tokenMint), + ParsableMintInfo, + ); + + const nonNativeAddresses = nonNativeMints.map(({ tokenMint }, index) => + getAssociatedTokenAddressSync( + tokenMint, + ownerAddress, + allowPDAOwnerAddress, + mints[index]!.tokenProgram, + ), + ); + + const tokenAccounts = await getMultipleParsedAccounts( + connection, + nonNativeAddresses, + ParsableTokenAccountInfo, + ); + + tokenAccounts.forEach((tokenAccount, index) => { + const ataAddress = nonNativeAddresses[index]!; + let resolvedInstruction: ResolvedTokenAddressInstruction; + if (tokenAccount) { + // ATA whose owner has been changed is abnormal entity. + // To prevent to send swap/withdraw/collect output to the ATA, an error should be thrown. + if (!tokenAccount.owner.equals(ownerAddress)) { + throw new Error( + `ATA with change of ownership detected: ${ataAddress.toBase58()}`, + ); + } + + resolvedInstruction = { + address: ataAddress, + tokenProgram: tokenAccount.tokenProgram, + ...EMPTY_INSTRUCTION, + }; + } else { + const createAtaInstruction = modeIdempotent + ? createAssociatedTokenAccountIdempotentInstruction( + payer, + ataAddress, + ownerAddress, + nonNativeMints[index]!.tokenMint, + mints[index]!.tokenProgram, + ) + : createAssociatedTokenAccountInstruction( + payer, + ataAddress, + ownerAddress, + nonNativeMints[index]!.tokenMint, + mints[index]!.tokenProgram, + ); + + resolvedInstruction = { + address: ataAddress, + tokenProgram: mints[index]!.tokenProgram, + instructions: [createAtaInstruction], + cleanupInstructions: [], + signers: [], + }; + } + instructionMap[nonNativeMints[index].tokenMint.toBase58()] = + resolvedInstruction; + }); + } + + if (nativeMints.length > 0) { + const accountRentExempt = await getAccountRentExempt(); + const wrappedSolAmountIn = nativeMints[0]?.wrappedSolAmountIn || ZERO; + instructionMap[NATIVE_MINT.toBase58()] = + TokenUtil.createWrappedNativeAccountInstruction( + ownerAddress, + wrappedSolAmountIn, + accountRentExempt, + payer, + undefined, // use default + wrappedSolAccountCreateMethod, + ); + } + + // Preserve order of resolution + return requests.map(({ tokenMint }) => instructionMap[tokenMint.toBase58()]); +} diff --git a/legacy-sdk/common/src/web3/index.ts b/legacy-sdk/common/src/web3/index.ts new file mode 100644 index 000000000..34412b25f --- /dev/null +++ b/legacy-sdk/common/src/web3/index.ts @@ -0,0 +1,8 @@ +export * from "./address-util"; +export * from "./ata-util"; +export type * from "./lookup-table-fetcher"; +export * from "./network"; +export * from "./public-key-utils"; +export * from "./token-util"; +export * from "./transactions"; +export * from "./wallet"; diff --git a/legacy-sdk/common/src/web3/lookup-table-fetcher.ts b/legacy-sdk/common/src/web3/lookup-table-fetcher.ts new file mode 100644 index 000000000..fb597b62d --- /dev/null +++ b/legacy-sdk/common/src/web3/lookup-table-fetcher.ts @@ -0,0 +1,30 @@ +import type { AddressLookupTableAccount, PublicKey } from "@solana/web3.js"; + +export interface LookupTable { + address: string; + containedAddresses: string[]; +} + +/** + * Interface for fetching lookup tables for a set of addresses. + * + * Implementations of this class is expected to cache the lookup tables for quicker read lookups. + */ +export interface LookupTableFetcher { + /** + * Given a set of public key addresses, fetches the lookup table accounts that contains these addresses + * and caches them for future lookups. + * @param addresses The addresses to fetch lookup tables for. + * @return The lookup tables that contains the given addresses. + */ + loadLookupTables(addresses: PublicKey[]): Promise; + + /** + * Given a set of public key addresses, fetches the lookup table accounts that contains these addresses. + * @param addresses - The addresses to fetch lookup tables for. + * @return The lookup table accounts that contains the given addresses. + */ + getLookupTableAccountsForAddresses( + addresses: PublicKey[], + ): Promise; +} diff --git a/legacy-sdk/common/src/web3/network/account-requests.ts b/legacy-sdk/common/src/web3/network/account-requests.ts new file mode 100644 index 000000000..d10257a51 --- /dev/null +++ b/legacy-sdk/common/src/web3/network/account-requests.ts @@ -0,0 +1,117 @@ +import type { AccountInfo, Connection, PublicKey } from "@solana/web3.js"; +import invariant from "tiny-invariant"; +import type { Address } from "../address-util"; +import { AddressUtil } from "../address-util"; +import type { ParsableEntity } from "./parsing"; + +export async function getParsedAccount( + connection: Connection, + address: Address, + parser: ParsableEntity, +): Promise { + const value = await connection.getAccountInfo(AddressUtil.toPubKey(address)); + const key = AddressUtil.toPubKey(address); + return parser.parse(key, value); +} + +export async function getMultipleParsedAccounts( + connection: Connection, + addresses: Address[], + parser: ParsableEntity, + chunkSize = 100, +): Promise<(T | null)[]> { + if (addresses.length === 0) { + return []; + } + + const values = await getMultipleAccounts( + connection, + AddressUtil.toPubKeys(addresses), + 10, + chunkSize, + ); + const results = values.map((val) => { + if (val[1] === null) { + return null; + } + return parser.parse(val[0], val[1]); + }); + invariant(results.length === addresses.length, "not enough results fetched"); + return results; +} + +// An entry between the key of an address and the account data for that address. +export type FetchedAccountEntry = [PublicKey, AccountInfo | null]; +export type FetchedAccountMap = Map | null>; + +export async function getMultipleAccountsInMap( + connection: Connection, + addresses: Address[], + timeoutAfterSeconds = 10, + chunkSize = 100, +): Promise> { + const results = await getMultipleAccounts( + connection, + addresses, + timeoutAfterSeconds, + chunkSize, + ); + return results.reduce((map, [key, value]) => { + map.set(key.toBase58(), value); + return map; + }, new Map | null>()); +} + +export async function getMultipleAccounts( + connection: Connection, + addresses: Address[], + timeoutAfterSeconds = 10, + chunkSize = 100, +): Promise> { + if (addresses.length === 0) { + return []; + } + + const promises: Promise[] = []; + const chunks = Math.ceil(addresses.length / chunkSize); + const result: Array = new Array( + chunks, + ); + + for (let i = 0; i < result.length; i++) { + const slice = addresses.slice(i * chunkSize, (i + 1) * chunkSize); + const addressChunk = AddressUtil.toPubKeys(slice); + const promise = new Promise(async (resolve) => { + const res = await connection.getMultipleAccountsInfo(addressChunk); + const fetchedAccountChunk = res.map((result, index) => { + return [addressChunk[index], result] as FetchedAccountEntry; + }); + result[i] = fetchedAccountChunk; + resolve(); + }); + promises.push(promise); + } + + await Promise.race([ + Promise.all(promises), + timeoutAfter( + timeoutAfterSeconds, + "connection.getMultipleAccountsInfo timeout", + ), + ]); + + const flattenedResult = result.flat(); + invariant( + flattenedResult.length === addresses.length, + "getMultipleAccounts not enough results", + ); + return flattenedResult; +} + +function timeoutAfter(seconds: number, message: string) { + return new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(message)); + }, seconds * 1000); + }); +} diff --git a/legacy-sdk/common/src/web3/network/fetcher/index.ts b/legacy-sdk/common/src/web3/network/fetcher/index.ts new file mode 100644 index 000000000..cafbacd67 --- /dev/null +++ b/legacy-sdk/common/src/web3/network/fetcher/index.ts @@ -0,0 +1,68 @@ +import type { + AccountWithTokenProgram, + MintWithTokenProgram, + ParsableEntity, +} from ".."; +import type { Address } from "../../address-util"; + +export * from "./simple-fetcher-impl"; + +export type BasicSupportedTypes = + | AccountWithTokenProgram + | MintWithTokenProgram; + +/** + * Interface for fetching and caching on-chain accounts + */ +export interface AccountFetcher { + /** + * Fetch an account from the cache or from the network + * @param address The account address to fetch from cache or network + * @param parser The parser to used for theses accounts + * @param opts Options when fetching the accounts + * @returns + */ + getAccount: ( + address: Address, + parser: ParsableEntity, + opts?: AccountFetchOptions, + ) => Promise; + + /** + * Fetch multiple accounts from the cache or from the network + * @param address A list of account addresses to fetch from cache or network + * @param parser The parser to used for theses accounts + * @param opts Options when fetching the accounts + * @returns a Map of addresses to accounts. The ordering of the Map iteration is the same as the ordering of the input addresses. + */ + getAccounts: ( + address: Address[], + parser: ParsableEntity, + opts?: AccountFetchOptions, + ) => Promise>; + + /** + * Fetch multiple accounts from the cache or from the network and return as an array + * @param address A list of account addresses to fetch from cache or network + * @param parser The parser to used for theses accounts + * @param opts Options when fetching the accounts + * @returns an array of accounts. The ordering of the array is the same as the ordering of the input addresses. + */ + getAccountsAsArray: ( + address: Address[], + parser: ParsableEntity, + opts?: AccountFetchOptions, + ) => Promise>; + + /** + * Populate the cache with the given accounts. + * @param accounts A list of accounts addresses to fetched accounts to populate the cache with + * @param parser The parser that was used to parse theses accounts + * @param now The timestamp to use for the cache entries + */ + populateAccounts: ( + accounts: ReadonlyMap, + parser: ParsableEntity, + now: number, + ) => void; +} diff --git a/legacy-sdk/common/src/web3/network/fetcher/simple-fetcher-impl.ts b/legacy-sdk/common/src/web3/network/fetcher/simple-fetcher-impl.ts new file mode 100644 index 000000000..1c0d27056 --- /dev/null +++ b/legacy-sdk/common/src/web3/network/fetcher/simple-fetcher-impl.ts @@ -0,0 +1,185 @@ +import type { Connection } from "@solana/web3.js"; +import type { AccountFetcher } from "."; +import type { Address } from "../../address-util"; +import { AddressUtil } from "../../address-util"; +import { getMultipleAccountsInMap } from "../account-requests"; +import type { ParsableEntity } from "../parsing"; + +type CachedContent = { + parser: ParsableEntity; + fetchedAt: number; + value: T | null; +}; + +export type RetentionPolicy = ReadonlyMap, number>; + +/** + * Options when fetching the accounts + */ +export type SimpleAccountFetchOptions = { + // Accepted maxAge in milliseconds for a cache entry hit for this account request. + maxAge?: number; + // Timeout in seconds before the RPC call is considered failed. + timeoutInSeconds?: number; +}; + +// SimpleAccountFetcher is a simple implementation of AccountCache that stores the fetched +// accounts in memory. If TTL is not provided, it will use TTL defined in the the retention policy +// for the parser. If that is also not provided, the request will always prefer the cache value. +export class SimpleAccountFetcher< + T, + FetchOptions extends SimpleAccountFetchOptions, +> implements AccountFetcher +{ + cache: Map> = new Map(); + constructor( + readonly connection: Connection, + readonly retentionPolicy: RetentionPolicy, + ) { + this.cache = new Map>(); + } + + async getAccount( + address: Address, + parser: ParsableEntity, + opts?: FetchOptions | undefined, + now: number = Date.now(), + ): Promise { + const addressKey = AddressUtil.toPubKey(address); + const addressStr = AddressUtil.toString(address); + + const cached = this.cache.get(addressStr); + const maxAge = this.getMaxAge(this.retentionPolicy.get(parser), opts); + const elapsed = !!cached + ? now - (cached?.fetchedAt ?? 0) + : Number.NEGATIVE_INFINITY; + const expired = elapsed > maxAge; + + if (!!cached && !expired) { + return cached.value as U | null; + } + + try { + const accountInfo = await this.connection.getAccountInfo(addressKey); + const value = parser.parse(addressKey, accountInfo); + this.cache.set(addressStr, { parser, value, fetchedAt: now }); + return value; + } catch { + this.cache.set(addressStr, { parser, value: null, fetchedAt: now }); + return null; + } + } + + private getMaxAge( + parserMaxAge?: number, + opts?: SimpleAccountFetchOptions, + ): number { + if (opts?.maxAge !== undefined) { + return opts.maxAge; + } + return parserMaxAge === undefined ? Number.POSITIVE_INFINITY : parserMaxAge; + } + + async getAccounts( + addresses: Address[], + parser: ParsableEntity, + opts?: SimpleAccountFetchOptions | undefined, + now: number = Date.now(), + ): Promise> { + const addressStrs = AddressUtil.toStrings(addresses); + await this.fetchAndPopulateCache(addressStrs, parser, opts, now); + + // Build a map of the results, insert by the order of the addresses parameter + const result = new Map(); + addressStrs.forEach((addressStr) => { + const cached = this.cache.get(addressStr); + const value = cached?.value as U | null; + result.set(addressStr, value); + }); + + return result; + } + + async getAccountsAsArray( + addresses: Address[], + parser: ParsableEntity, + opts?: FetchOptions | undefined, + now: number = Date.now(), + ): Promise> { + const addressStrs = AddressUtil.toStrings(addresses); + await this.fetchAndPopulateCache(addressStrs, parser, opts, now); + + // Rebuild an array containing the results, insert by the order of the addresses parameter + const result = new Array(); + addressStrs.forEach((addressStr) => { + const cached = this.cache.get(addressStr); + const value = cached?.value as U | null; + result.push(value); + }); + + return result; + } + + populateAccounts( + accounts: ReadonlyMap, + parser: ParsableEntity, + now: number, + ): void { + Array.from(accounts.entries()).forEach(([key, value]) => { + this.cache.set(key, { parser, value, fetchedAt: now }); + }); + } + + async refreshAll(now: number = Date.now(), timeoutInSeconds?: number) { + const addresses = Array.from(this.cache.keys()); + const fetchedAccountsMap = await getMultipleAccountsInMap( + this.connection, + addresses, + timeoutInSeconds, + ); + + for (const [key, cachedContent] of this.cache.entries()) { + const parser = cachedContent.parser; + const fetchedEntry = fetchedAccountsMap.get(key); + const value = parser.parse(AddressUtil.toPubKey(key), fetchedEntry); + this.cache.set(key, { parser, value, fetchedAt: now }); + } + } + + private async fetchAndPopulateCache( + addresses: Address[], + parser: ParsableEntity, + opts?: SimpleAccountFetchOptions | undefined, + now: number = Date.now(), + ) { + const addressStrs = AddressUtil.toStrings(addresses); + const maxAge = this.getMaxAge(this.retentionPolicy.get(parser), opts); + + // Filter out all unexpired accounts to get the accounts to fetch + const undefinedAccounts = addressStrs.filter((addressStr) => { + const cached = this.cache.get(addressStr); + const elapsed = !!cached + ? now - (cached?.fetchedAt ?? 0) + : Number.NEGATIVE_INFINITY; + const expired = elapsed > maxAge; + return !cached || expired; + }); + + // Fetch all undefined accounts and place in cache + // TODO: We currently do not support contextSlot consistency across the batched getMultipleAccounts call + // If the addresses list contain accounts in the 1st gMA call as subsequent calls and the gMA returns on different contextSlots, + // the returned results can be inconsistent and unexpected by the user. + if (undefinedAccounts.length > 0) { + const fetchedAccountsMap = await getMultipleAccountsInMap( + this.connection, + undefinedAccounts, + opts?.timeoutInSeconds, + ); + undefinedAccounts.forEach((key) => { + const fetchedEntry = fetchedAccountsMap.get(key); + const value = parser.parse(AddressUtil.toPubKey(key), fetchedEntry); + this.cache.set(key, { parser, value, fetchedAt: now }); + }); + } + } +} diff --git a/legacy-sdk/common/src/web3/network/index.ts b/legacy-sdk/common/src/web3/network/index.ts new file mode 100644 index 000000000..60685c29e --- /dev/null +++ b/legacy-sdk/common/src/web3/network/index.ts @@ -0,0 +1,4 @@ +export * from "./account-requests"; +export * from "./fetcher"; +export * from "./parsing"; +export type * from "./types"; diff --git a/legacy-sdk/common/src/web3/network/parsing.ts b/legacy-sdk/common/src/web3/network/parsing.ts new file mode 100644 index 000000000..65e70784a --- /dev/null +++ b/legacy-sdk/common/src/web3/network/parsing.ts @@ -0,0 +1,88 @@ +import { unpackAccount, unpackMint } from "@solana/spl-token"; +import type { AccountInfo, PublicKey } from "@solana/web3.js"; +import type { AccountWithTokenProgram, MintWithTokenProgram } from "./types"; + +/** + * Static abstract class definition to parse entities. + * @category Parsables + */ +export interface ParsableEntity { + /** + * Parse account data + * + * @param accountData Buffer data for the entity + * @returns Parsed entity + */ + parse: ( + address: PublicKey, + accountData: AccountInfo | undefined | null, + ) => T | null; +} + +/** + * @category Parsables + */ +@staticImplements>() +export class ParsableTokenAccountInfo { + private constructor() {} + + public static parse( + address: PublicKey, + data: AccountInfo | undefined | null, + ): AccountWithTokenProgram | null { + if (!data) { + return null; + } + + try { + return { + ...unpackAccount(address, data, data.owner), + tokenProgram: data.owner, + }; + } catch (e) { + console.error( + `error while parsing TokenAccount ${address.toBase58()}: ${e}`, + ); + + return null; + } + } +} + +/** + * @category Parsables + */ +@staticImplements>() +export class ParsableMintInfo { + private constructor() {} + + public static parse( + address: PublicKey, + data: AccountInfo | undefined | null, + ): MintWithTokenProgram | null { + if (!data) { + return null; + } + + try { + return { + ...unpackMint(address, data, data.owner), + tokenProgram: data.owner, + }; + } catch (e) { + console.error(`error while parsing Mint ${address.toBase58()}: ${e}`); + return null; + } + } +} + +/** + * Class decorator to define an interface with static methods + * Reference: https://github.com/Microsoft/TypeScript/issues/13462#issuecomment-295685298 + */ +export function staticImplements() { + return (constructor: U) => { + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + constructor; + }; +} diff --git a/legacy-sdk/common/src/web3/network/types.ts b/legacy-sdk/common/src/web3/network/types.ts new file mode 100644 index 000000000..d473557c6 --- /dev/null +++ b/legacy-sdk/common/src/web3/network/types.ts @@ -0,0 +1,12 @@ +import type { Account, Mint } from "@solana/spl-token"; +import type { PublicKey } from "@solana/web3.js"; + +/** + * @category Parsables + */ +export type MintWithTokenProgram = Mint & { tokenProgram: PublicKey }; + +/** + * @category Parsables + */ +export type AccountWithTokenProgram = Account & { tokenProgram: PublicKey }; diff --git a/legacy-sdk/common/src/web3/public-key-utils.ts b/legacy-sdk/common/src/web3/public-key-utils.ts new file mode 100644 index 000000000..4dd4348e2 --- /dev/null +++ b/legacy-sdk/common/src/web3/public-key-utils.ts @@ -0,0 +1,41 @@ +import type { PublicKey } from "@solana/web3.js"; + +export class PublicKeyUtils { + /** + * Check whether a string is a Base58 string. + * @param value + * @returns Whether the string is a Base58 string. + */ + public static isBase58(value: string) { + return /^[A-HJ-NP-Za-km-z1-9]*$/.test(value); + } + + /** + * Order a list of public keys by bytes. + * @param keys a list of public keys to order + * @returns an ordered array of public keys + */ + public static orderKeys(...keys: PublicKey[]): PublicKey[] { + return keys.sort(comparePublicKeys); + } +} + +function comparePublicKeys(key1: PublicKey, key2: PublicKey): number { + const bytes1 = key1.toBytes(); + const bytes2 = key2.toBytes(); + + // PublicKeys should be zero-padded 32 byte length + if (bytes1.byteLength !== bytes2.byteLength) { + return bytes1.byteLength - bytes2.byteLength; + } + + for (let i = 0; i < bytes1.byteLength; i++) { + let byte1 = bytes1[i]; + let byte2 = bytes2[i]; + if (byte1 !== byte2) { + return byte1 - byte2; + } + } + + return 0; +} diff --git a/legacy-sdk/common/src/web3/token-util.ts b/legacy-sdk/common/src/web3/token-util.ts new file mode 100644 index 000000000..8db067599 --- /dev/null +++ b/legacy-sdk/common/src/web3/token-util.ts @@ -0,0 +1,323 @@ +import { + AccountLayout, + NATIVE_MINT, + NATIVE_MINT_2022, + TOKEN_PROGRAM_ID, + createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, + createInitializeAccountInstruction, + createSyncNativeInstruction, + createTransferCheckedWithTransferHookInstruction, + getAssociatedTokenAddressSync, +} from "@solana/spl-token"; +import type { Connection, TransactionInstruction } from "@solana/web3.js"; +import { Keypair, PublicKey, SystemProgram } from "@solana/web3.js"; +import { sha256 } from "@noble/hashes/sha256"; +import type BN from "bn.js"; +import invariant from "tiny-invariant"; +import { ZERO } from "../math"; +import type { Instruction } from "../web3"; +import { resolveOrCreateATA } from "../web3"; +/** + * @category Util + */ +export type ResolvedTokenAddressInstruction = { + address: PublicKey; + tokenProgram: PublicKey; +} & Instruction; + +/** + * @category Util + */ +export type WrappedSolAccountCreateMethod = "keypair" | "withSeed" | "ata"; + +/** + * @category Util + */ +export class TokenUtil { + public static isNativeMint(mint: PublicKey) { + return mint.equals(NATIVE_MINT); + } + + /** + * Create an ix to send a native-mint and unwrap it to the user's wallet. + * @param owner - PublicKey for the owner of the temporary WSOL account. + * @param amountIn - Amount of SOL to wrap. + * @param rentExemptLamports - Rent exempt lamports for the temporary WSOL account. + * @param payer - PublicKey for the payer that would fund the temporary WSOL accounts. (must sign the txn) + * @param unwrapDestination - PublicKey for the receiver that would receive the unwrapped SOL including rent. + * @param createAccountMethod - How to create the temporary WSOL account. + * @returns + */ + public static createWrappedNativeAccountInstruction( + owner: PublicKey, + amountIn: BN, + rentExemptLamports: number, + payer?: PublicKey, + unwrapDestination?: PublicKey, + createAccountMethod: WrappedSolAccountCreateMethod = "keypair", + ): ResolvedTokenAddressInstruction { + const payerKey = payer ?? owner; + const unwrapDestinationKey = unwrapDestination ?? owner; + + switch (createAccountMethod) { + case "ata": + return createWrappedNativeAccountInstructionWithATA( + owner, + amountIn, + rentExemptLamports, + payerKey, + unwrapDestinationKey, + ); + case "keypair": + return createWrappedNativeAccountInstructionWithKeypair( + owner, + amountIn, + rentExemptLamports, + payerKey, + unwrapDestinationKey, + ); + case "withSeed": + return createWrappedNativeAccountInstructionWithSeed( + owner, + amountIn, + rentExemptLamports, + payerKey, + unwrapDestinationKey, + ); + default: + throw new Error(`Invalid createAccountMethod: ${createAccountMethod}`); + } + } + + /** + * Create an ix to send a spl-token / native-mint to another wallet. + * This function will handle the associated token accounts internally for spl-token. + * SOL is sent directly to the user's wallet. + * + * @param connection - Connection object + * @param sourceWallet - PublicKey for the sender's wallet + * @param destinationWallet - PublicKey for the receiver's wallet + * @param tokenMint - Mint for the token that is being sent. + * @param tokenDecimals - Decimal for the token that is being sent. + * @param amount - Amount of token to send + * @param getAccountRentExempt - Fn to fetch the account rent exempt value + * @param payer - PublicKey for the payer that would fund the possibly new token-accounts. (must sign the txn) + * @param allowPDASourceWallet - Allow PDA to be used as the source wallet. + * @returns + */ + static async createSendTokensToWalletInstruction( + connection: Connection, + sourceWallet: PublicKey, + destinationWallet: PublicKey, + tokenMint: PublicKey, + tokenDecimals: number, + amount: BN, + getAccountRentExempt: () => Promise, + payer?: PublicKey, + allowPDASourceWallet: boolean = false, + ): Promise { + invariant( + !amount.eq(ZERO), + "SendToken transaction must send more than 0 tokens.", + ); + invariant( + !tokenMint.equals(NATIVE_MINT_2022), + "NATIVE_MINT_2022 is not supported.", + ); + + // Specifically handle SOL, which is not a spl-token. + if (tokenMint.equals(NATIVE_MINT)) { + const sendSolTxn = SystemProgram.transfer({ + fromPubkey: sourceWallet, + toPubkey: destinationWallet, + lamports: BigInt(amount.toString()), + }); + return { + instructions: [sendSolTxn], + cleanupInstructions: [], + signers: [], + }; + } + + const mintAccountInfo = await connection.getAccountInfo(tokenMint); + if (mintAccountInfo === null) throw Error("Cannot fetch tokenMint."); + const tokenProgram = mintAccountInfo.owner; + + const sourceTokenAccount = getAssociatedTokenAddressSync( + tokenMint, + sourceWallet, + allowPDASourceWallet, + tokenProgram, + ); + const { address: destinationTokenAccount, ...destinationAtaIx } = + await resolveOrCreateATA( + connection, + destinationWallet, + tokenMint, + getAccountRentExempt, + amount, + payer, + undefined, + true, + ); + + const transferIx = await createTransferCheckedWithTransferHookInstruction( + connection, + sourceTokenAccount, + tokenMint, + destinationTokenAccount, + sourceWallet, + BigInt(amount.toString()), + tokenDecimals, + undefined, + undefined, + tokenProgram, + ); + + return { + instructions: destinationAtaIx.instructions.concat(transferIx), + cleanupInstructions: destinationAtaIx.cleanupInstructions, + signers: destinationAtaIx.signers, + }; + } +} + +function createWrappedNativeAccountInstructionWithATA( + owner: PublicKey, + amountIn: BN, + _rentExemptLamports: number, + payerKey: PublicKey, + unwrapDestinationKey: PublicKey, +): ResolvedTokenAddressInstruction { + const tempAccount = getAssociatedTokenAddressSync(NATIVE_MINT, owner); + + const instructions: TransactionInstruction[] = [ + createAssociatedTokenAccountIdempotentInstruction( + payerKey, + tempAccount, + owner, + NATIVE_MINT, + ), + ]; + + if (amountIn.gt(ZERO)) { + instructions.push( + SystemProgram.transfer({ + fromPubkey: payerKey, + toPubkey: tempAccount, + lamports: amountIn.toNumber(), + }), + ); + + instructions.push(createSyncNativeInstruction(tempAccount)); + } + + const closeWSOLAccountInstruction = createCloseAccountInstruction( + tempAccount, + unwrapDestinationKey, + owner, + ); + + return { + address: tempAccount, + tokenProgram: TOKEN_PROGRAM_ID, + instructions, + cleanupInstructions: [closeWSOLAccountInstruction], + signers: [], + }; +} + +function createWrappedNativeAccountInstructionWithKeypair( + owner: PublicKey, + amountIn: BN, + rentExemptLamports: number, + payerKey: PublicKey, + unwrapDestinationKey: PublicKey, +): ResolvedTokenAddressInstruction { + const tempAccount = new Keypair(); + + const createAccountInstruction = SystemProgram.createAccount({ + fromPubkey: payerKey, + newAccountPubkey: tempAccount.publicKey, + lamports: amountIn.toNumber() + rentExemptLamports, + space: AccountLayout.span, + programId: TOKEN_PROGRAM_ID, + }); + + const initAccountInstruction = createInitializeAccountInstruction( + tempAccount.publicKey, + NATIVE_MINT, + owner, + ); + + const closeWSOLAccountInstruction = createCloseAccountInstruction( + tempAccount.publicKey, + unwrapDestinationKey, + owner, + ); + + return { + address: tempAccount.publicKey, + tokenProgram: TOKEN_PROGRAM_ID, + instructions: [createAccountInstruction, initAccountInstruction], + cleanupInstructions: [closeWSOLAccountInstruction], + signers: [tempAccount], + }; +} + +function createWrappedNativeAccountInstructionWithSeed( + owner: PublicKey, + amountIn: BN, + rentExemptLamports: number, + payerKey: PublicKey, + unwrapDestinationKey: PublicKey, +): ResolvedTokenAddressInstruction { + // seed is always shorter than a signature. + // So createWrappedNativeAccountInstructionWithSeed always generates small size instructions + // than createWrappedNativeAccountInstructionWithKeypair. + const seed = Keypair.generate().publicKey.toBase58().slice(0, 32); // 32 chars + + const tempAccount = (() => { + // same to PublicKey.createWithSeed, but this one is synchronous + const fromPublicKey = owner; + const programId = TOKEN_PROGRAM_ID; + const buffer = Buffer.concat([ + fromPublicKey.toBuffer(), + Buffer.from(seed), + programId.toBuffer(), + ]); + const publicKeyBytes = sha256(buffer); + return new PublicKey(publicKeyBytes); + })(); + + const createAccountInstruction = SystemProgram.createAccountWithSeed({ + fromPubkey: payerKey, + basePubkey: owner, + seed, + newAccountPubkey: tempAccount, + lamports: amountIn.toNumber() + rentExemptLamports, + space: AccountLayout.span, + programId: TOKEN_PROGRAM_ID, + }); + + const initAccountInstruction = createInitializeAccountInstruction( + tempAccount, + NATIVE_MINT, + owner, + ); + + const closeWSOLAccountInstruction = createCloseAccountInstruction( + tempAccount, + unwrapDestinationKey, + owner, + ); + + return { + address: tempAccount, + tokenProgram: TOKEN_PROGRAM_ID, + instructions: [createAccountInstruction, initAccountInstruction], + cleanupInstructions: [closeWSOLAccountInstruction], + signers: [], + }; +} diff --git a/legacy-sdk/common/src/web3/transactions/compute-budget.ts b/legacy-sdk/common/src/web3/transactions/compute-budget.ts new file mode 100644 index 000000000..dc13585d1 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/compute-budget.ts @@ -0,0 +1,124 @@ +import type { + AddressLookupTableAccount, + Connection, + RecentPrioritizationFees, +} from "@solana/web3.js"; +import { + ComputeBudgetProgram, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import type { Instruction } from "./types"; +import BN from "bn.js"; + +export const MICROLAMPORTS_PER_LAMPORT = 1_000_000; +export const DEFAULT_PRIORITY_FEE_PERCENTILE = 0.9; +export const DEFAULT_MAX_PRIORITY_FEE_LAMPORTS = 1000000; // 0.001 SOL +export const DEFAULT_MIN_PRIORITY_FEE_LAMPORTS = 0; // 0 SOL +export const DEFAULT_MAX_COMPUTE_UNIT_LIMIT = 1_400_000; + +export async function estimateComputeBudgetLimit( + connection: Connection, + instructions: Instruction[], + lookupTableAccounts: AddressLookupTableAccount[] | undefined, + payer: PublicKey, + margin: number, +): Promise { + try { + const txMainInstructions = instructions.flatMap( + (instruction) => instruction.instructions, + ); + const txCleanupInstruction = instructions.flatMap( + (instruction) => instruction.cleanupInstructions, + ); + const txMessage = new TransactionMessage({ + recentBlockhash: PublicKey.default.toBase58(), + payerKey: payer, + instructions: [...txMainInstructions, ...txCleanupInstruction], + }).compileToV0Message(lookupTableAccounts); + + const tx = new VersionedTransaction(txMessage); + + const simulation = await connection.simulateTransaction(tx, { + sigVerify: false, + replaceRecentBlockhash: true, + }); + if (!simulation.value.unitsConsumed) { + return DEFAULT_MAX_COMPUTE_UNIT_LIMIT; + } + const marginUnits = Math.max( + 100_000, + margin * simulation.value.unitsConsumed, + ); + const estimatedUnits = Math.ceil( + simulation.value.unitsConsumed + marginUnits, + ); + return Math.min(DEFAULT_MAX_COMPUTE_UNIT_LIMIT, estimatedUnits); + } catch { + return DEFAULT_MAX_COMPUTE_UNIT_LIMIT; + } +} + +export async function getPriorityFeeInLamports( + connection: Connection, + computeBudgetLimit: number, + lockedWritableAccounts: PublicKey[], + percentile: number = DEFAULT_PRIORITY_FEE_PERCENTILE, + getRecentPrioritizationFees?: ( + lockedWritableAccounts: PublicKey[], + ) => Promise, +): Promise { + const recentPriorityFees = await (getRecentPrioritizationFees + ? getRecentPrioritizationFees(lockedWritableAccounts) + : connection.getRecentPrioritizationFees({ + lockedWritableAccounts, + })); + const priorityFee = getPriorityFeeSuggestion(recentPriorityFees, percentile); + return (priorityFee * computeBudgetLimit) / MICROLAMPORTS_PER_LAMPORT; +} + +function getPriorityFeeSuggestion( + recentPriorityFees: RecentPrioritizationFees[], + percentile: number, +): number { + // Take the Xth percentile of all the slots returned + const sortedPriorityFees = recentPriorityFees.sort( + (a, b) => a.prioritizationFee - b.prioritizationFee, + ); + const percentileIndex = Math.min( + Math.max(Math.floor(sortedPriorityFees.length * percentile), 0), + sortedPriorityFees.length - 1, + ); + return sortedPriorityFees[percentileIndex].prioritizationFee; +} + +export function getLockWritableAccounts( + instructions: Instruction[], +): PublicKey[] { + return instructions + .flatMap((instruction) => [ + ...instruction.instructions, + ...instruction.cleanupInstructions, + ]) + .flatMap((instruction) => instruction.keys) + .filter((key) => key.isWritable) + .map((key) => key.pubkey); +} + +const SET_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_INSTRUCTION_DISCRIMINATOR = + Buffer.from([0x04]); +export function setLoadedAccountsDataSizeLimitInstruction( + dataSizeLimit: BN | number, +): TransactionInstruction { + const dataSizeLimitBn = new BN(dataSizeLimit); + return new TransactionInstruction({ + programId: ComputeBudgetProgram.programId, + data: Buffer.concat([ + SET_LOADED_ACCOUNTS_DATA_SIZE_LIMIT_INSTRUCTION_DISCRIMINATOR, + dataSizeLimitBn.toArrayLike(Buffer, "le", 4), + ]), + keys: [], + }); +} diff --git a/legacy-sdk/common/src/web3/transactions/constants.ts b/legacy-sdk/common/src/web3/transactions/constants.ts new file mode 100644 index 000000000..a5887eb97 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/constants.ts @@ -0,0 +1,13 @@ +import { PACKET_DATA_SIZE } from "@solana/web3.js"; + +// The hard-coded limit of a transaction size in bytes +export const TX_SIZE_LIMIT = PACKET_DATA_SIZE; // 1232 + +// The hard-coded limit of an encoded transaction size in bytes +export const TX_BASE64_ENCODED_SIZE_LIMIT = Math.ceil(TX_SIZE_LIMIT / 3) * 4; // 1644 + +// A dummy blockhash to use for measuring transaction sizes +export const MEASUREMENT_BLOCKHASH = { + blockhash: "65FJ2gp6jC2x87bycfdZpxDyjiodcAoymxR6PMZzfavY", + lastValidBlockHeight: 160381350, +}; diff --git a/legacy-sdk/common/src/web3/transactions/index.ts b/legacy-sdk/common/src/web3/transactions/index.ts new file mode 100644 index 000000000..5ad167ec1 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/index.ts @@ -0,0 +1,5 @@ +export * from "./compute-budget"; +export * from "./constants"; +export * from "./transactions-builder"; +export * from "./transactions-processor"; +export * from "./types"; diff --git a/legacy-sdk/common/src/web3/transactions/jito-tip.ts b/legacy-sdk/common/src/web3/transactions/jito-tip.ts new file mode 100644 index 000000000..a4bf34126 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/jito-tip.ts @@ -0,0 +1,21 @@ +import { PublicKey } from "@solana/web3.js"; + +// https://jito-foundation.gitbook.io/mev/mev-payment-and-distribution/on-chain-addresses +const jitoTipAddresses = [ + "96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5", + "HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe", + "Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY", + "ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49", + "DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh", + "ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt", + "DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL", + "3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT", +]; + +export function getJitoTipAddress(): PublicKey { + // just pick a random one from the list. There are multiple addresses so that no single one + // can cause local congestion. + return new PublicKey( + jitoTipAddresses[Math.floor(Math.random() * jitoTipAddresses.length)], + ); +} diff --git a/legacy-sdk/common/src/web3/transactions/transactions-builder.ts b/legacy-sdk/common/src/web3/transactions/transactions-builder.ts new file mode 100644 index 000000000..1a9f5275a --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/transactions-builder.ts @@ -0,0 +1,623 @@ +import type { + AddressLookupTableAccount, + Commitment, + Connection, + PublicKey, + RecentPrioritizationFees, + SendOptions, + Signer, + TransactionInstruction, +} from "@solana/web3.js"; +import { + ComputeBudgetProgram, + PACKET_DATA_SIZE, + SystemProgram, + Transaction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import type { Wallet } from "../wallet"; +import { + DEFAULT_MAX_COMPUTE_UNIT_LIMIT, + DEFAULT_MAX_PRIORITY_FEE_LAMPORTS, + DEFAULT_MIN_PRIORITY_FEE_LAMPORTS, + DEFAULT_PRIORITY_FEE_PERCENTILE, + MICROLAMPORTS_PER_LAMPORT, + estimateComputeBudgetLimit, + getLockWritableAccounts, + getPriorityFeeInLamports, + setLoadedAccountsDataSizeLimitInstruction, +} from "./compute-budget"; +import { MEASUREMENT_BLOCKHASH } from "./constants"; +import type { Instruction, TransactionPayload } from "./types"; +import { getJitoTipAddress } from "./jito-tip"; + +/** + Build options when building a transaction using TransactionBuilder + @param latestBlockhash + The latest blockhash to use when building the transaction. + @param blockhashCommitment + If latestBlockhash is not provided, the commitment level to use when fetching the latest blockhash. + @param maxSupportedTransactionVersion + The transaction version to build. If set to "legacy", the transaction will + be built using the legacy transaction format. Otherwise, the transaction + will be built using the VersionedTransaction format. + @param lookupTableAccounts + If the build support VersionedTransactions, allow providing the lookup + table accounts to use when building the transaction. This is only used + when maxSupportedTransactionVersion is set to a number. + @param computeBudgetOption + The compute budget limit and priority fee to use when building the transaction. + This defaults to 'none'. + */ +export type BuildOptions = LegacyBuildOption | V0BuildOption; + +type LegacyBuildOption = { + maxSupportedTransactionVersion: "legacy"; +} & BaseBuildOption; + +type V0BuildOption = { + maxSupportedTransactionVersion: number; + lookupTableAccounts?: AddressLookupTableAccount[]; +} & BaseBuildOption; + +type BaseBuildOption = { + latestBlockhash?: { + blockhash: string; + lastValidBlockHeight: number; + }; + computeBudgetOption?: ComputeBudgetOption; + blockhashCommitment: Commitment; +}; + +type ComputeBudgetOption = + | { + type: "none"; + } + | { + type: "fixed"; + priorityFeeLamports: number; + computeBudgetLimit?: number; + jitoTipLamports?: number; + accountDataSizeLimit?: number; + } + | { + type: "auto"; + maxPriorityFeeLamports?: number; + minPriorityFeeLamports?: number; + jitoTipLamports?: number; + accountDataSizeLimit?: number; + computeLimitMargin?: number; + computePricePercentile?: number; + getPriorityFeePerUnit?: ( + lockedWritableAccounts: PublicKey[], + ) => Promise; + }; + +type SyncBuildOptions = BuildOptions & Required; + +const LEGACY_TX_UNIQUE_KEYS_LIMIT = 35; + +/** + * A set of options that the builder will use by default, unless overridden by the user in each method. + */ +export type TransactionBuilderOptions = { + defaultBuildOption: BuildOptions; + defaultSendOption: SendOptions; + defaultConfirmationCommitment: Commitment; +}; + +export const defaultTransactionBuilderOptions: TransactionBuilderOptions = { + defaultBuildOption: { + maxSupportedTransactionVersion: 0, + blockhashCommitment: "confirmed", + }, + defaultSendOption: { + skipPreflight: false, + preflightCommitment: "confirmed", + maxRetries: 3, + }, + defaultConfirmationCommitment: "confirmed", +}; + +/** + * Transaction builder for composing, building and sending transactions. + * @category Transactions + */ +export class TransactionBuilder { + private instructions: Instruction[]; + private signers: Signer[]; + readonly opts: TransactionBuilderOptions; + + constructor( + readonly connection: Connection, + readonly wallet: Wallet, + defaultOpts?: TransactionBuilderOptions, + ) { + this.instructions = []; + this.signers = []; + this.opts = defaultOpts ?? defaultTransactionBuilderOptions; + } + + /** + * Append an instruction into this builder. + * @param instruction - An Instruction + * @returns Returns this transaction builder. + */ + addInstruction(instruction: Instruction): TransactionBuilder { + this.instructions.push(instruction); + return this; + } + + /** + * Append a list of instructions into this builder. + * @param instructions - A list of Instructions + * @returns Returns this transaction builder. + */ + addInstructions(instructions: Instruction[]): TransactionBuilder { + this.instructions = this.instructions.concat(instructions); + return this; + } + + /** + * Prepend a list of instructions into this builder. + * @param instruction - An Instruction + * @returns Returns this transaction builder. + */ + prependInstruction(instruction: Instruction): TransactionBuilder { + this.instructions.unshift(instruction); + return this; + } + + /** + * Prepend a list of instructions into this builder. + * @param instructions - A list of Instructions + * @returns Returns this transaction builder. + */ + prependInstructions(instructions: Instruction[]): TransactionBuilder { + this.instructions = instructions.concat(this.instructions); + return this; + } + + addSigner(signer: Signer): TransactionBuilder { + this.signers.push(signer); + return this; + } + + /** + * Checks whether this builder contains any instructions. + * @returns Whether this builder contains any instructions. + */ + isEmpty(): boolean { + return this.instructions.length == 0; + } + + /** + * Compresses all instructions & signers in this builder + * into one single instruction + * @param compressPost Compress all post instructions into the instructions field + * @returns Instruction object containing all + */ + compressIx(compressPost: boolean): Instruction { + let instructions: TransactionInstruction[] = []; + let cleanupInstructions: TransactionInstruction[] = []; + let signers: Signer[] = []; + this.instructions.forEach((curr) => { + instructions = instructions.concat(curr.instructions); + // Cleanup instructions should execute in reverse order + cleanupInstructions = + curr.cleanupInstructions.concat(cleanupInstructions); + signers = signers.concat(curr.signers); + }); + + if (compressPost) { + instructions = instructions.concat(cleanupInstructions); + cleanupInstructions = []; + } + + return { + instructions: [...instructions], + cleanupInstructions: [...cleanupInstructions], + signers, + }; + } + + /** + * Returns the size of the current transaction in bytes. Measurement method can differ based on the maxSupportedTransactionVersion. + * @param userOptions - Options to override the default build options + * @returns the size of the current transaction in bytes. + * @throws error if there is an error measuring the transaction size. + * This can happen if the transaction is too large, or if the transaction contains too many keys to be serialized. + */ + txnSize(userOptions?: Partial): number { + const finalOptions: SyncBuildOptions = { + ...this.opts.defaultBuildOption, + ...userOptions, + latestBlockhash: MEASUREMENT_BLOCKHASH, + computeBudgetOption: this.opts.defaultBuildOption.computeBudgetOption ?? { + type: "none", + }, + }; + if (this.isEmpty()) { + return 0; + } + const request = this.buildSync(finalOptions); + const tx = request.transaction; + return isVersionedTransaction(tx) ? measureV0Tx(tx) : measureLegacyTx(tx); + } + + /** + * Constructs a transaction payload with the gathered instructions synchronously + * @param options - Options used to build the transaction + * @returns a TransactionPayload object that can be excuted or agregated into other transactions + */ + buildSync(options: SyncBuildOptions): TransactionPayload { + const { + latestBlockhash, + maxSupportedTransactionVersion, + computeBudgetOption, + } = options; + + const ix = this.compressIx(true); + let prependInstructions: TransactionInstruction[] = []; + + if (computeBudgetOption.type === "fixed") { + const computeLimit = + computeBudgetOption.computeBudgetLimit ?? + DEFAULT_MAX_COMPUTE_UNIT_LIMIT; + const microLamports = Math.floor( + (computeBudgetOption.priorityFeeLamports * MICROLAMPORTS_PER_LAMPORT) / + computeLimit, + ); + + prependInstructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: computeLimit, + }), + ]; + if (microLamports > 0) { + prependInstructions.push( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports, + }), + ); + } + if (computeBudgetOption.accountDataSizeLimit) { + prependInstructions.push( + setLoadedAccountsDataSizeLimitInstruction( + computeBudgetOption.accountDataSizeLimit, + ), + ); + } + if ( + computeBudgetOption.jitoTipLamports && + computeBudgetOption.jitoTipLamports > 0 + ) { + prependInstructions.push( + SystemProgram.transfer({ + fromPubkey: this.wallet.publicKey, + toPubkey: getJitoTipAddress(), + lamports: computeBudgetOption.jitoTipLamports, + }), + ); + } + } + + if (computeBudgetOption.type === "auto") { + // Auto only works using `build` so when we encounter `auto` here we + // just use the use 0 priority budget and default compute budget. + // This should only be happening for calucling the tx size so it should be fine. + prependInstructions = [ + ComputeBudgetProgram.setComputeUnitLimit({ + units: DEFAULT_MAX_COMPUTE_UNIT_LIMIT, + }), + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: 0, + }), + ]; + if (computeBudgetOption.accountDataSizeLimit) { + prependInstructions.push( + setLoadedAccountsDataSizeLimitInstruction( + computeBudgetOption.accountDataSizeLimit, + ), + ); + } + if ( + computeBudgetOption.jitoTipLamports && + computeBudgetOption.jitoTipLamports > 0 + ) { + prependInstructions.push( + SystemProgram.transfer({ + fromPubkey: this.wallet.publicKey, + toPubkey: getJitoTipAddress(), + lamports: computeBudgetOption.jitoTipLamports, + }), + ); + } + } + + const allSigners = ix.signers.concat(this.signers); + + const recentBlockhash = latestBlockhash; + + if (maxSupportedTransactionVersion === "legacy") { + const transaction = new Transaction({ + ...recentBlockhash, + feePayer: this.wallet.publicKey, + }); + if (prependInstructions.length > 0) { + transaction.add(...prependInstructions); + } + transaction.add(...ix.instructions); + transaction.feePayer = this.wallet.publicKey; + + return { + transaction: transaction, + signers: allSigners, + recentBlockhash, + }; + } + + const txnMsg = new TransactionMessage({ + recentBlockhash: recentBlockhash.blockhash, + payerKey: this.wallet.publicKey, + instructions: [...prependInstructions, ...ix.instructions], + }); + + const { lookupTableAccounts } = options; + + const msg = txnMsg.compileToV0Message(lookupTableAccounts); + const v0txn = new VersionedTransaction(msg); + + return { + transaction: v0txn, + signers: allSigners, + recentBlockhash, + }; + } + + /** + * Estimates the fee for this transaction + * @param getPriorityFeePerUnit - A function to get the priority fee per unit + * @param computeLimitMargin - The margin for the compute budget limit + * @param selectionPercentile - The percentile to use when calculating the priority fee + * @param lookupTableAccounts - The lookup table accounts that will be used in the transaction + * @returns An object containing the estimated values for consumed compute units, priority fee per unit in lamports, and the total priority fee in lamports + */ + async estimateFee( + getPriorityFeePerUnit?: ( + lockedWritableAccounts: PublicKey[], + ) => Promise, + computeLimitMargin?: number, + selectionPercentile?: number, + lookupTableAccounts?: AddressLookupTableAccount[], + ) { + const estConsumedComputeUnits = await estimateComputeBudgetLimit( + this.connection, + this.instructions, + lookupTableAccounts, + this.wallet.publicKey, + computeLimitMargin ?? 0.1, + ); + + const lockedWritableAccounts = getLockWritableAccounts(this.instructions); + + const estPriorityFeePerUnitInLamports = await (getPriorityFeePerUnit + ? getPriorityFeePerUnit(lockedWritableAccounts) + : this.connection.getRecentPrioritizationFees({ + lockedWritableAccounts, + })); + + const estPriorityFeeInLamports = await getPriorityFeeInLamports( + this.connection, + estConsumedComputeUnits, + lockedWritableAccounts, + selectionPercentile ?? DEFAULT_PRIORITY_FEE_PERCENTILE, + getPriorityFeePerUnit, + ); + + return { + estConsumedComputeUnits, + estPriorityFeePerUnitInLamports, + estPriorityFeeInLamports, + }; + } + + /** + * Constructs a transaction payload with the gathered instructions + * @param userOptions - Options to override the default build options + * @returns a TransactionPayload object that can be excuted or agregated into other transactions + */ + async build( + userOptions?: Partial, + ): Promise { + const finalOptions = { ...this.opts.defaultBuildOption, ...userOptions }; + const { latestBlockhash, blockhashCommitment, computeBudgetOption } = + finalOptions; + let recentBlockhash = latestBlockhash; + if (!recentBlockhash) { + recentBlockhash = + await this.connection.getLatestBlockhash(blockhashCommitment); + } + let finalComputeBudgetOption = computeBudgetOption ?? { type: "none" }; + + const lookupTableAccounts = + finalOptions.maxSupportedTransactionVersion === "legacy" + ? undefined + : finalOptions.lookupTableAccounts; + + if (finalComputeBudgetOption.type === "auto") { + const computeBudgetLimit = await estimateComputeBudgetLimit( + this.connection, + this.instructions, + lookupTableAccounts, + this.wallet.publicKey, + finalComputeBudgetOption.computeLimitMargin ?? 0.1, + ); + const percentile = + finalComputeBudgetOption.computePricePercentile ?? + DEFAULT_PRIORITY_FEE_PERCENTILE; + const priorityFee = await getPriorityFeeInLamports( + this.connection, + computeBudgetLimit, + getLockWritableAccounts(this.instructions), + percentile, + finalComputeBudgetOption.getPriorityFeePerUnit, + ); + const maxPriorityFeeLamports = + finalComputeBudgetOption.maxPriorityFeeLamports ?? + DEFAULT_MAX_PRIORITY_FEE_LAMPORTS; + const minPriorityFeeLamports = + finalComputeBudgetOption.minPriorityFeeLamports ?? + DEFAULT_MIN_PRIORITY_FEE_LAMPORTS; + const priorityFeeLamports = Math.max( + Math.min(priorityFee, maxPriorityFeeLamports), + minPriorityFeeLamports, + ); + finalComputeBudgetOption = { + type: "fixed", + priorityFeeLamports, + computeBudgetLimit, + accountDataSizeLimit: finalComputeBudgetOption.accountDataSizeLimit, + jitoTipLamports: finalComputeBudgetOption.jitoTipLamports, + }; + } else if ( + finalComputeBudgetOption.type === "fixed" && + finalComputeBudgetOption.computeBudgetLimit === undefined + ) { + const computeBudgetLimit = await estimateComputeBudgetLimit( + this.connection, + this.instructions, + lookupTableAccounts, + this.wallet.publicKey, + 0.1, + ); + finalComputeBudgetOption = { + ...finalComputeBudgetOption, + computeBudgetLimit, + }; + } + return this.buildSync({ + ...finalOptions, + latestBlockhash: recentBlockhash, + computeBudgetOption: finalComputeBudgetOption, + }); + } + + /** + * Constructs a transaction payload with the gathered instructions, sign it with the provider and send it out + * @param options - Options to build the transaction. . Overrides the default options provided in the constructor. + * @param sendOptions - Options to send the transaction. Overrides the default options provided in the constructor. + * @param confirmCommitment - Commitment level to wait for transaction confirmation. Overrides the default options provided in the constructor. + * @returns the txId of the transaction + */ + async buildAndExecute( + options?: Partial, + sendOptions?: Partial, + confirmCommitment?: Commitment, + ): Promise { + const sendOpts = { ...this.opts.defaultSendOption, ...sendOptions }; + const btx = await this.build(options); + const txn = btx.transaction; + const resolvedConfirmCommitment = + confirmCommitment ?? this.opts.defaultConfirmationCommitment; + + let txId: string; + if (isVersionedTransaction(txn)) { + const signedTxn = await this.wallet.signTransaction(txn); + signedTxn.sign(btx.signers); + txId = await this.connection.sendTransaction(signedTxn, sendOpts); + } else { + const signedTxn = await this.wallet.signTransaction(txn); + btx.signers + .filter((s): s is Signer => s !== undefined) + .forEach((keypair) => signedTxn.partialSign(keypair)); + txId = await this.connection.sendRawTransaction( + signedTxn.serialize(), + sendOpts, + ); + } + + const result = await this.connection.confirmTransaction( + { + signature: txId, + ...btx.recentBlockhash, + }, + resolvedConfirmCommitment, + ); + + const confirmTxErr = result.value.err; + if (confirmTxErr) { + throw new Error(confirmTxErr.toString()); + } + + return txId; + } +} + +/** + * Checks if a transaction is a versioned transaction. + * @param tx Transaction to check. + * @returns True if the transaction is a versioned transaction. + */ +export const isVersionedTransaction = ( + tx: Transaction | VersionedTransaction, +): tx is VersionedTransaction => { + return "version" in tx; +}; + +function measureLegacyTx(tx: Transaction): number { + // Due to the high cost of serialize, if the number of unique accounts clearly exceeds the limit of legacy transactions, + // serialize is not performed and a determination of infeasibility is made. + const uniqueKeys = new Set(); + for (const instruction of tx.instructions) { + for (const key of instruction.keys) { + uniqueKeys.add(key.pubkey.toBase58()); + } + uniqueKeys.add(instruction.programId.toBase58()); + } + if (uniqueKeys.size > LEGACY_TX_UNIQUE_KEYS_LIMIT) { + throw new Error( + "Unable to measure transaction size. Too many unique keys in transaction.", + ); + } + + try { + // (Legacy)Transaction.serialize ensures that the size of successfully serialized data + // is less than or equal to PACKET_DATA_SIZE(1232). + // https://github.com/solana-labs/solana-web3.js/blob/77f78a8/packages/library-legacy/src/transaction/legacy.ts#L806 + const serialized = tx.serialize({ requireAllSignatures: false }); + return serialized.length; + } catch { + throw new Error( + "Unable to measure transaction size. Unable to serialize transaction.", + ); + } +} + +function measureV0Tx(tx: VersionedTransaction): number { + let serialized: Uint8Array; + try { + serialized = tx.serialize(); + } catch { + throw new Error( + "Unable to measure transaction size. Unable to serialize transaction.", + ); + } + + // VersionedTransaction.serialize does NOT ensures that the size of successfully serialized data is + // less than or equal to PACKET_DATA_SIZE(1232). + // https://github.com/solana-labs/solana-web3.js/blob/77f78a8/packages/library-legacy/src/transaction/versioned.ts#L65 + // + // BufferLayout.encode throws an error for writes that exceed the buffer size, + // so obviously large transactions will throws an error. + // However, depending on the size of the signature and message body, a size between 1233 - 2048 may be returned + // as a successful result, so we need to check it here. + if (serialized.length > PACKET_DATA_SIZE) { + throw new Error( + "Unable to measure transaction size. Transaction too large.", + ); + } + + return serialized.length; +} diff --git a/legacy-sdk/common/src/web3/transactions/transactions-processor.ts b/legacy-sdk/common/src/web3/transactions/transactions-processor.ts new file mode 100644 index 000000000..8d0e74473 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/transactions-processor.ts @@ -0,0 +1,174 @@ +import type { + Commitment, + Connection, + PublicKey, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; +import type { Wallet } from "../wallet"; +import { isVersionedTransaction } from "./transactions-builder"; +import type { SendTxRequest } from "./types"; + +/** + * @deprecated + */ +export class TransactionProcessor { + constructor( + readonly connection: Connection, + readonly wallet: Wallet, + readonly commitment: Commitment = "confirmed", + ) {} + + public async signTransaction(txRequest: SendTxRequest): Promise<{ + transaction: Transaction | VersionedTransaction; + lastValidBlockHeight: number; + blockhash: string; + }> { + const { transactions, lastValidBlockHeight, blockhash } = + await this.signTransactions([txRequest]); + return { transaction: transactions[0], lastValidBlockHeight, blockhash }; + } + + public async signTransactions(txRequests: SendTxRequest[]): Promise<{ + transactions: (Transaction | VersionedTransaction)[]; + lastValidBlockHeight: number; + blockhash: string; + }> { + const { blockhash, lastValidBlockHeight } = + await this.connection.getLatestBlockhash(this.commitment); + const feePayer = this.wallet.publicKey; + const pSignedTxs = txRequests.map((txRequest) => { + return rewriteTransaction(txRequest, feePayer, blockhash); + }); + const transactions = await this.wallet.signAllTransactions(pSignedTxs); + return { + transactions, + lastValidBlockHeight, + blockhash, + }; + } + + public async sendTransaction( + transaction: Transaction | VersionedTransaction, + lastValidBlockHeight: number, + blockhash: string, + ): Promise { + const execute = this.constructSendTransactions( + [transaction], + lastValidBlockHeight, + blockhash, + ); + const txs = await execute(); + const ex = txs[0]; + if (ex.status === "fulfilled") { + return ex.value; + } else { + throw ex.reason; + } + } + + public constructSendTransactions( + transactions: (Transaction | VersionedTransaction)[], + lastValidBlockHeight: number, + blockhash: string, + parallel: boolean = true, + ): () => Promise[]> { + const executeTx = async (tx: Transaction | VersionedTransaction) => { + const rawTxs = tx.serialize(); + return this.connection.sendRawTransaction(rawTxs, { + preflightCommitment: this.commitment, + }); + }; + + const confirmTx = async (txId: string) => { + const result = await this.connection.confirmTransaction( + { + signature: txId, + lastValidBlockHeight: lastValidBlockHeight, + blockhash, + }, + this.commitment, + ); + + if (result.value.err) { + throw new Error(`Transaction failed: ${JSON.stringify(result.value)}`); + } + }; + + return async () => { + if (parallel) { + const results = transactions.map(async (tx) => { + const txId = await executeTx(tx); + await confirmTx(txId); + return txId; + }); + + return Promise.allSettled(results); + } else { + const results = []; + for (const tx of transactions) { + const txId = await executeTx(tx); + await confirmTx(txId); + results.push(txId); + } + return Promise.allSettled(results); + } + }; + } + + public async signAndConstructTransaction(txRequest: SendTxRequest): Promise<{ + signedTx: Transaction | VersionedTransaction; + execute: () => Promise; + }> { + const { transaction, lastValidBlockHeight, blockhash } = + await this.signTransaction(txRequest); + return { + signedTx: transaction, + execute: async () => + this.sendTransaction(transaction, lastValidBlockHeight, blockhash), + }; + } + + public async signAndConstructTransactions( + txRequests: SendTxRequest[], + parallel: boolean = true, + ): Promise<{ + signedTxs: (Transaction | VersionedTransaction)[]; + execute: () => Promise[]>; + }> { + const { transactions, lastValidBlockHeight, blockhash } = + await this.signTransactions(txRequests); + const execute = this.constructSendTransactions( + transactions, + lastValidBlockHeight, + blockhash, + parallel, + ); + return { signedTxs: transactions, execute }; + } +} + +function rewriteTransaction( + txRequest: SendTxRequest, + feePayer: PublicKey, + blockhash: string, +) { + if (isVersionedTransaction(txRequest.transaction)) { + let tx: VersionedTransaction = txRequest.transaction; + if (txRequest.signers) { + tx.sign(txRequest.signers ?? []); + } + return tx; + } else { + let tx: Transaction = txRequest.transaction; + let signers = txRequest.signers ?? []; + + tx.feePayer = feePayer; + tx.recentBlockhash = blockhash; + + signers.forEach((kp) => { + tx.partialSign(kp); + }); + return tx; + } +} diff --git a/legacy-sdk/common/src/web3/transactions/types.ts b/legacy-sdk/common/src/web3/transactions/types.ts new file mode 100644 index 000000000..63d10b492 --- /dev/null +++ b/legacy-sdk/common/src/web3/transactions/types.ts @@ -0,0 +1,43 @@ +import type { + BlockhashWithExpiryBlockHeight, + Signer, + Transaction, + TransactionInstruction, + VersionedTransaction, +} from "@solana/web3.js"; + +/** + * @category Transactions Util + */ +export const EMPTY_INSTRUCTION: Instruction = { + instructions: [], + cleanupInstructions: [], + signers: [], +}; + +/** + * @category Transactions Util + */ +export type Instruction = { + instructions: TransactionInstruction[]; + cleanupInstructions: TransactionInstruction[]; + signers: Signer[]; +}; + +/** + * @category Transactions Util + */ +export type TransactionPayload = { + transaction: Transaction | VersionedTransaction; + signers: Signer[]; + recentBlockhash: BlockhashWithExpiryBlockHeight; +}; + +/** + * @category Transactions Util + * @deprecated + */ +export type SendTxRequest = { + transaction: Transaction | VersionedTransaction; + signers?: Signer[]; +}; diff --git a/legacy-sdk/common/src/web3/wallet.ts b/legacy-sdk/common/src/web3/wallet.ts new file mode 100644 index 000000000..1ce493ef0 --- /dev/null +++ b/legacy-sdk/common/src/web3/wallet.ts @@ -0,0 +1,27 @@ +import type { Transaction, VersionedTransaction } from "@solana/web3.js"; +import { PublicKey } from "@solana/web3.js"; + +export interface Wallet { + signTransaction( + tx: T, + ): Promise; + signAllTransactions( + txs: T[], + ): Promise; + publicKey: PublicKey; +} + +export class ReadOnlyWallet implements Wallet { + constructor(public publicKey: PublicKey = PublicKey.default) {} + + signTransaction( + _transaction: T, + ): Promise { + throw new Error("Read only wallet cannot sign transaction."); + } + signAllTransactions( + _transactions: T[], + ): Promise { + throw new Error("Read only wallet cannot sign transactions."); + } +} diff --git a/legacy-sdk/common/tests/test-context.ts b/legacy-sdk/common/tests/test-context.ts new file mode 100644 index 000000000..fe054cff3 --- /dev/null +++ b/legacy-sdk/common/tests/test-context.ts @@ -0,0 +1,67 @@ +import { + createAssociatedTokenAccountIdempotent, + createMint, +} from "@solana/spl-token"; +import type { PublicKey } from "@solana/web3.js"; +import { Connection, Keypair, LAMPORTS_PER_SOL } from "@solana/web3.js"; +import TestWallet from "./utils/test-wallet"; +export const DEFAULT_RPC_ENDPOINT_URL = "http://localhost:8899"; + +export interface TestContext { + connection: Connection; + wallet: TestWallet; +} + +export function createTestContext( + url: string = DEFAULT_RPC_ENDPOINT_URL, +): TestContext { + return { + connection: new Connection(url, "confirmed"), + wallet: new TestWallet(Keypair.generate()), + }; +} + +export async function requestAirdrop( + ctx: TestContext, + numSol: number = 1000, +): Promise { + const signature = await ctx.connection.requestAirdrop( + ctx.wallet.publicKey, + numSol * LAMPORTS_PER_SOL, + ); + const latestBlockhash = await ctx.connection.getLatestBlockhash(); + await ctx.connection.confirmTransaction({ signature, ...latestBlockhash }); +} + +export function createNewMint( + ctx: TestContext, + tokenProgramId: PublicKey, +): Promise { + return createMint( + ctx.connection, + ctx.wallet.payer, + ctx.wallet.publicKey, + ctx.wallet.publicKey, + 6, + undefined, + undefined, + tokenProgramId, + ); +} + +export async function createAssociatedTokenAccount( + ctx: TestContext, + tokenProgramId: PublicKey, + mint?: PublicKey, +): Promise<{ ata: PublicKey; mint: PublicKey }> { + let tokenMint = mint || (await createNewMint(ctx, tokenProgramId)); + const ataKey = await createAssociatedTokenAccountIdempotent( + ctx.connection, + ctx.wallet.payer, + tokenMint, + ctx.wallet.publicKey, + undefined, + tokenProgramId, + ); + return { ata: ataKey, mint: tokenMint }; +} diff --git a/legacy-sdk/common/tests/utils/expectations.ts b/legacy-sdk/common/tests/utils/expectations.ts new file mode 100644 index 000000000..cb2f6611d --- /dev/null +++ b/legacy-sdk/common/tests/utils/expectations.ts @@ -0,0 +1,11 @@ +import type { Mint } from "@solana/spl-token"; + +export function expectMintEquals(actual: Mint, expected: Mint) { + expect(actual.decimals).toEqual(expected.decimals); + expect(actual.isInitialized).toEqual(expected.isInitialized); + expect(actual.mintAuthority!.equals(expected.mintAuthority!)).toBeTruthy(); + expect( + actual.freezeAuthority!.equals(expected.freezeAuthority!), + ).toBeTruthy(); + expect(actual.supply === expected.supply).toBeTruthy(); +} diff --git a/legacy-sdk/common/tests/utils/test-wallet.ts b/legacy-sdk/common/tests/utils/test-wallet.ts new file mode 100644 index 000000000..b585e1c6f --- /dev/null +++ b/legacy-sdk/common/tests/utils/test-wallet.ts @@ -0,0 +1,42 @@ +import type { + Keypair, + PublicKey, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; +import type { Wallet } from "../../src/web3"; +import { isVersionedTransaction } from "../../src/web3"; + +// Ported from @coral-xyz/anchor for testing purposes. +export default class TestWallet implements Wallet { + constructor(readonly payer: Keypair) {} + + async signTransaction( + tx: T, + ): Promise { + if (isVersionedTransaction(tx)) { + tx.sign([this.payer]); + } else { + tx.partialSign(this.payer); + } + + return tx; + } + + async signAllTransactions( + txs: T[], + ): Promise { + return txs.map((t) => { + if (isVersionedTransaction(t)) { + t.sign([this.payer]); + } else { + t.partialSign(this.payer); + } + return t; + }); + } + + get publicKey(): PublicKey { + return this.payer.publicKey; + } +} diff --git a/legacy-sdk/common/tests/web3/ata-util.test.ts b/legacy-sdk/common/tests/web3/ata-util.test.ts new file mode 100644 index 000000000..ce9bc4c00 --- /dev/null +++ b/legacy-sdk/common/tests/web3/ata-util.test.ts @@ -0,0 +1,620 @@ +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + AccountLayout, + NATIVE_MINT, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, + createAssociatedTokenAccount, + getAccount, + getAssociatedTokenAddressSync, + setAuthority, +} from "@solana/spl-token"; +import { Keypair, LAMPORTS_PER_SOL, SystemProgram } from "@solana/web3.js"; +import { BN } from "bn.js"; +import { ZERO } from "../../src/math"; +import { + resolveOrCreateATA, + resolveOrCreateATAs, +} from "../../src/web3/ata-util"; +import { TransactionBuilder } from "../../src/web3/transactions"; +import { + createNewMint, + createTestContext, + requestAirdrop, +} from "../test-context"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("ata-util", () => { + const ctx = createTestContext(); + const { connection, wallet } = ctx; + + beforeAll(async () => { + await requestAirdrop(ctx); + }); + + const tokenPrograms = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; + tokenPrograms.forEach((tokenProgram) => + describe(`TokenProgram: ${tokenProgram.toBase58()}`, () => { + it("resolveOrCreateATA, wrapped sol", async () => { + const { connection, wallet } = ctx; + + // verify address & instruction + const notExpected = getAssociatedTokenAddressSync( + wallet.publicKey, + NATIVE_MINT, + ); + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + NATIVE_MINT, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + new BN(LAMPORTS_PER_SOL), + wallet.publicKey, + false, + ); + expect(resolved.address.equals(notExpected)).toBeFalsy(); // non-ATA address + expect(resolved.instructions.length).toEqual(2); + expect( + resolved.instructions[0].programId.equals(SystemProgram.programId), + ).toBeTruthy(); + expect( + resolved.instructions[1].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.cleanupInstructions.length).toEqual(1); + expect( + resolved.cleanupInstructions[0].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + }); + + it("resolveOrCreateATA, not exist, modeIdempotent = false", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + // verify address & instruction + const expected = getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + ); + expect(resolved.address.equals(expected)).toBeTruthy(); + expect(resolved.instructions.length).toEqual(1); + expect(resolved.instructions[0].data.length).toEqual(0); // no instruction data + + // verify transaction + const preAccountData = await connection.getAccountInfo( + resolved.address, + ); + expect(preAccountData).toBeNull(); + + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await builder.buildAndExecute(); + + const postAccountData = await connection.getAccountInfo( + resolved.address, + ); + expect(postAccountData?.owner.equals(tokenProgram)).toBeTruthy(); + }); + + it("resolveOrCreateATA, exist, modeIdempotent = false", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + const expected = await createAssociatedTokenAccount( + ctx.connection, + wallet.payer, + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const preAccountData = await connection.getAccountInfo(expected); + expect(preAccountData).not.toBeNull(); + + // verify address & instruction + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + ); + expect(resolved.address.equals(expected)).toBeTruthy(); + expect(resolved.instructions.length).toEqual(0); + }); + + it("resolveOrCreateATA, created before execution, modeIdempotent = false", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + const expected = getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + ); + expect(resolved.address.equals(expected)).toBeTruthy(); + expect(resolved.instructions.length).toEqual(1); + expect(resolved.instructions[0].data.length).toEqual(0); // no instruction data + + // created before execution + await createAssociatedTokenAccount( + connection, + wallet.payer, + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const accountData = await connection.getAccountInfo(expected); + expect(accountData).not.toBeNull(); + + // Tx should be fail + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).rejects.toThrow(); + }); + + it("resolveOrCreateATA, created before execution, modeIdempotent = true", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + const expected = getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + true, + ); + expect(resolved.address.equals(expected)).toBeTruthy(); + expect(resolved.instructions.length).toEqual(1); + expect(resolved.instructions[0].data[0]).toEqual(1); // 1 byte data + + // created before execution + await createAssociatedTokenAccount( + connection, + wallet.payer, + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + const accountData = await connection.getAccountInfo(expected); + expect(accountData).not.toBeNull(); + + // Tx should be success even if ATA has been created + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + }); + + it("resolveOrCreateATAs, created before execution, modeIdempotent = false", async () => { + const mints = await Promise.all([ + createNewMint(ctx, tokenProgram), + createNewMint(ctx, tokenProgram), + createNewMint(ctx, tokenProgram), + ]); + + // create first ATA + await createAssociatedTokenAccount( + connection, + wallet.payer, + mints[0], + wallet.publicKey, + undefined, + tokenProgram, + ); + + const expected = mints.map((mint) => + getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ), + ); + const resolved = await resolveOrCreateATAs( + connection, + wallet.publicKey, + mints.map((mint) => ({ tokenMint: mint, wrappedSolAmountIn: ZERO })), + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + wallet.publicKey, + false, + ); + expect(resolved[0].address.equals(expected[0])).toBeTruthy(); + expect(resolved[1].address.equals(expected[1])).toBeTruthy(); + expect(resolved[2].address.equals(expected[2])).toBeTruthy(); + expect(resolved[0].instructions.length).toEqual(0); // already exists + expect(resolved[1].instructions.length).toEqual(1); + expect(resolved[2].instructions.length).toEqual(1); + expect(resolved[1].instructions[0].data.length).toEqual(0); // no instruction data + expect(resolved[2].instructions[0].data.length).toEqual(0); // no instruction data + + // create second ATA before execution + await createAssociatedTokenAccount( + connection, + wallet.payer, + mints[1], + wallet.publicKey, + undefined, + tokenProgram, + ); + + const preAccountData = + await connection.getMultipleAccountsInfo(expected); + expect(preAccountData[0]).not.toBeNull(); + expect(preAccountData[1]).not.toBeNull(); + expect(preAccountData[2]).toBeNull(); + + // Tx should be fail + const builder = new TransactionBuilder(connection, wallet); + builder.addInstructions(resolved); + await expect(builder.buildAndExecute()).rejects.toThrow(); + }); + + it("resolveOrCreateATAs, created before execution, modeIdempotent = true", async () => { + const mints = await Promise.all([ + createNewMint(ctx, tokenProgram), + createNewMint(ctx, tokenProgram), + createNewMint(ctx, tokenProgram), + ]); + + // create first ATA + await createAssociatedTokenAccount( + connection, + wallet.payer, + mints[0], + wallet.publicKey, + undefined, + tokenProgram, + ); + + const expected = mints.map((mint) => + getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ), + ); + + const resolved = await resolveOrCreateATAs( + connection, + wallet.publicKey, + mints.map((mint) => ({ tokenMint: mint, wrappedSolAmountIn: ZERO })), + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + wallet.publicKey, + true, + ); + expect(resolved[0].address.equals(expected[0])).toBeTruthy(); + expect(resolved[1].address.equals(expected[1])).toBeTruthy(); + expect(resolved[2].address.equals(expected[2])).toBeTruthy(); + expect(resolved[0].instructions.length).toEqual(0); // already exists + expect(resolved[1].instructions.length).toEqual(1); + expect(resolved[2].instructions.length).toEqual(1); + expect(resolved[1].instructions[0].data[0]).toEqual(1); // 1 byte data + expect(resolved[2].instructions[0].data[0]).toEqual(1); // 1 byte data + + // create second ATA before execution + await createAssociatedTokenAccount( + connection, + wallet.payer, + mints[1], + wallet.publicKey, + undefined, + tokenProgram, + ); + + const preAccountData = + await connection.getMultipleAccountsInfo(expected); + expect(preAccountData[0]).not.toBeNull(); + expect(preAccountData[1]).not.toBeNull(); + expect(preAccountData[2]).toBeNull(); + + // Tx should be success even if second ATA has been created + const builder = new TransactionBuilder(connection, wallet); + builder.addInstructions(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + + const postAccountData = + await connection.getMultipleAccountsInfo(expected); + expect(postAccountData[0]).not.toBeNull(); + expect(postAccountData[1]).not.toBeNull(); + expect(postAccountData[2]).not.toBeNull(); + }); + + it("resolveOrCreateATA, owner changed ATA detected", async () => { + // in Token-2022, owner of ATA cannot be changed + if (tokenProgram.equals(TOKEN_2022_PROGRAM_ID)) return; + + const anotherWallet = Keypair.generate(); + const mint = await createNewMint(ctx, tokenProgram); + + const ata = await createAssociatedTokenAccount( + connection, + wallet.payer, + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); + + // should be ok + const preOwnerChanged = await resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ); + expect(preOwnerChanged.address.equals(ata)).toBeTruthy(); + + // owner change + await setAuthority( + connection, + ctx.wallet.payer, + ata, + wallet.publicKey, + 2, + anotherWallet.publicKey, + [], + ); + + // verify that owner have been changed + const changed = await getAccount(connection, ata); + expect(changed.owner.equals(anotherWallet.publicKey)).toBeTruthy(); + + // should be failed + const postOwnerChangedPromise = resolveOrCreateATA( + connection, + wallet.publicKey, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ); + await expect(postOwnerChangedPromise).rejects.toThrow( + /ATA with change of ownership detected/, + ); + }); + + it("resolveOrCreateATA, allowPDAOwnerAddress = false", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + const pda = getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); // ATA is one of PDAs + const allowPDAOwnerAddress = false; + + try { + await resolveOrCreateATA( + connection, + pda, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + allowPDAOwnerAddress, + ); + + fail("should be failed"); + } catch (e) { + expect(e.name).toMatch("TokenOwnerOffCurveError"); + } + }); + + it("resolveOrCreateATA, allowPDAOwnerAddress = true", async () => { + const mint = await createNewMint(ctx, tokenProgram); + + const pda = getAssociatedTokenAddressSync( + mint, + wallet.publicKey, + undefined, + tokenProgram, + ); // ATA is one of PDAs + const allowPDAOwnerAddress = true; + + try { + await resolveOrCreateATA( + connection, + pda, + mint, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + allowPDAOwnerAddress, + ); + } catch { + fail("should be failed"); + } + }); + + it("resolveOrCreateATA, wrappedSolAccountCreateMethod = ata", async () => { + const { connection, wallet } = ctx; + + const wrappedSolAccountCreateMethod = "ata"; + + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + NATIVE_MINT, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + new BN(LAMPORTS_PER_SOL), + wallet.publicKey, + false, + false, + wrappedSolAccountCreateMethod, + ); + + expect(resolved.instructions.length).toEqual(3); + expect( + resolved.instructions[0].programId.equals( + ASSOCIATED_TOKEN_PROGRAM_ID, + ), + ).toBeTruthy(); + expect( + resolved.instructions[1].programId.equals(SystemProgram.programId), + ).toBeTruthy(); + expect( + resolved.instructions[2].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.cleanupInstructions.length).toEqual(1); + expect( + resolved.cleanupInstructions[0].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.signers.length).toEqual(0); + + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + }); + + it("resolveOrCreateATA, wrappedSolAccountCreateMethod = ata, amount = 0", async () => { + const { connection, wallet } = ctx; + + const wrappedSolAccountCreateMethod = "ata"; + + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + NATIVE_MINT, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + false, + wrappedSolAccountCreateMethod, + ); + + expect(resolved.instructions.length).toEqual(1); + expect( + resolved.instructions[0].programId.equals( + ASSOCIATED_TOKEN_PROGRAM_ID, + ), + ).toBeTruthy(); + expect(resolved.cleanupInstructions.length).toEqual(1); + expect( + resolved.cleanupInstructions[0].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.signers.length).toEqual(0); + + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + }); + + it("resolveOrCreateATA, wrappedSolAccountCreateMethod = keypair", async () => { + const { connection, wallet } = ctx; + + const wrappedSolAccountCreateMethod = "keypair"; + + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + NATIVE_MINT, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + new BN(LAMPORTS_PER_SOL), + wallet.publicKey, + false, + false, + wrappedSolAccountCreateMethod, + ); + + expect(resolved.instructions.length).toEqual(2); + expect( + resolved.instructions[0].programId.equals(SystemProgram.programId), + ).toBeTruthy(); + expect( + resolved.instructions[1].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.cleanupInstructions.length).toEqual(1); + expect( + resolved.cleanupInstructions[0].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.signers.length).toEqual(1); + + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + }); + + it("resolveOrCreateATA, wrappedSolAccountCreateMethod = withSeed", async () => { + const { connection, wallet } = ctx; + + const wrappedSolAccountCreateMethod = "withSeed"; + + const resolved = await resolveOrCreateATA( + connection, + wallet.publicKey, + NATIVE_MINT, + () => + connection.getMinimumBalanceForRentExemption(AccountLayout.span), + new BN(LAMPORTS_PER_SOL), + wallet.publicKey, + false, + false, + wrappedSolAccountCreateMethod, + ); + + expect(resolved.instructions.length).toEqual(2); + expect( + resolved.instructions[0].programId.equals(SystemProgram.programId), + ).toBeTruthy(); + expect( + resolved.instructions[1].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.cleanupInstructions.length).toEqual(1); + expect( + resolved.cleanupInstructions[0].programId.equals(TOKEN_PROGRAM_ID), + ).toBeTruthy(); + expect(resolved.signers.length).toEqual(0); + + const builder = new TransactionBuilder(connection, wallet); + builder.addInstruction(resolved); + await expect(builder.buildAndExecute()).resolves.toBeTruthy(); + }); + }), + ); +}); diff --git a/legacy-sdk/common/tests/web3/network/account-requests.test.ts b/legacy-sdk/common/tests/web3/network/account-requests.test.ts new file mode 100644 index 000000000..04b99566d --- /dev/null +++ b/legacy-sdk/common/tests/web3/network/account-requests.test.ts @@ -0,0 +1,111 @@ +import { + getMint, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; +import { Keypair } from "@solana/web3.js"; +import { + getMultipleParsedAccounts, + getParsedAccount, + ParsableMintInfo, +} from "../../../src/web3"; +import { + createAssociatedTokenAccount, + createNewMint, + createTestContext, + requestAirdrop, +} from "../../test-context"; +import { expectMintEquals } from "../../utils/expectations"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("account-requests", () => { + const ctx = createTestContext(); + // Silence the errors when we evaluate invalid token accounts. + beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + beforeAll(async () => { + await requestAirdrop(ctx); + }); + + const tokenPrograms = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; + tokenPrograms.forEach((tokenProgram) => + describe(`TokenProgram: ${tokenProgram.toBase58()}`, () => { + it("getParsedAccount, ok", async () => { + const mint = await createNewMint(ctx, tokenProgram); + const expected = { + ...(await getMint(ctx.connection, mint, undefined, tokenProgram)), + tokenProgram, + }; + + const mintInfo = await getParsedAccount( + ctx.connection, + mint, + ParsableMintInfo, + ); + expectMintEquals(mintInfo!, expected); + }); + + it("getMultipleParsedAccounts, some null", async () => { + const mint = await createNewMint(ctx, tokenProgram); + const missing = Keypair.generate().publicKey; + const mintInfos = await getMultipleParsedAccounts( + ctx.connection, + [mint, missing], + ParsableMintInfo, + ); + + const expected = { + ...(await getMint(ctx.connection, mint, undefined, tokenProgram)), + tokenProgram, + }; + + expectMintEquals(mintInfos[0]!, expected); + expect(mintInfos[1]).toBeNull(); + }); + + it("getMultipleParsedAccounts, invalid type returns null", async () => { + const mint = await createNewMint(ctx, tokenProgram); + const { ata } = await createAssociatedTokenAccount( + ctx, + tokenProgram, + mint, + ); + const mintInfos = await getMultipleParsedAccounts( + ctx.connection, + [mint, ata], + ParsableMintInfo, + ); + const expected = { + ...(await getMint(ctx.connection, mint, undefined, tokenProgram)), + tokenProgram, + }; + expectMintEquals(mintInfos[0]!, expected); + expect(mintInfos[1]).toBeNull(); + }); + + it("getMultipleParsedAccounts, separate chunks", async () => { + const mints = await Promise.all( + Array.from( + { length: 10 }, + async () => await createNewMint(ctx, tokenProgram), + ), + ); + const mintInfos = await getMultipleParsedAccounts( + ctx.connection, + mints, + ParsableMintInfo, + 2, + ); + + // Verify all mints are fetched and are in order + expect(mintInfos.length === mints.length); + mints.forEach((mint, i) => { + expect(mintInfos[i]!.address.equals(mint)); + }); + }); + }), + ); +}); diff --git a/legacy-sdk/common/tests/web3/network/parsing.test.ts b/legacy-sdk/common/tests/web3/network/parsing.test.ts new file mode 100644 index 000000000..7f5541403 --- /dev/null +++ b/legacy-sdk/common/tests/web3/network/parsing.test.ts @@ -0,0 +1,57 @@ +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { ParsableMintInfo, ParsableTokenAccountInfo } from "../../../src/web3"; +import { + createAssociatedTokenAccount, + createNewMint, + createTestContext, + requestAirdrop, +} from "../../test-context"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("parsing", () => { + const ctx = createTestContext(); + + beforeAll(async () => { + await requestAirdrop(ctx); + }); + + const tokenPrograms = [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]; + tokenPrograms.forEach((tokenProgram) => + describe(`TokenProgram: ${tokenProgram.toBase58()}`, () => { + it("ParsableMintInfo", async () => { + const mint = await createNewMint(ctx, tokenProgram); + const account = await ctx.connection.getAccountInfo(mint); + const parsed = ParsableMintInfo.parse(mint, account); + + expect(parsed).toBeDefined(); + if (!parsed) { + throw new Error("parsed is undefined"); + } + const parsedData = parsed; + expect(parsedData.isInitialized).toEqual(true); + expect(parsedData.decimals).toEqual(6); + expect(parsedData.tokenProgram.equals(TOKEN_PROGRAM_ID)); + }); + + it("ParsableTokenAccountInfo", async () => { + const { ata, mint } = await createAssociatedTokenAccount( + ctx, + tokenProgram, + ); + const account = await ctx.connection.getAccountInfo(ata); + const parsed = ParsableTokenAccountInfo.parse(ata, account); + + expect(parsed).toBeDefined(); + if (!parsed) { + throw new Error("parsed is undefined"); + } + const parsedData = parsed; + expect(parsedData.mint.equals(mint)).toBeTruthy(); + expect(parsedData.tokenProgram.equals(TOKEN_PROGRAM_ID)); + expect(parsedData.isInitialized).toEqual(true); + expect(parsedData.amount === 0n).toBeTruthy(); + }); + }), + ); +}); diff --git a/legacy-sdk/common/tests/web3/network/simple-fetcher-impl.test.ts b/legacy-sdk/common/tests/web3/network/simple-fetcher-impl.test.ts new file mode 100644 index 000000000..d54bc9caf --- /dev/null +++ b/legacy-sdk/common/tests/web3/network/simple-fetcher-impl.test.ts @@ -0,0 +1,718 @@ +import type { Mint } from "@solana/spl-token"; +import { TOKEN_PROGRAM_ID, getMint } from "@solana/spl-token"; +import { PublicKey } from "@solana/web3.js"; +import type { BasicSupportedTypes, ParsableEntity } from "../../../src/web3"; +import { + ParsableMintInfo, + ParsableTokenAccountInfo, + SimpleAccountFetcher, +} from "../../../src/web3"; +import type { TestContext } from "../../test-context"; +import { + createNewMint, + createTestContext, + requestAirdrop, +} from "../../test-context"; +import { expectMintEquals } from "../../utils/expectations"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("simple-account-fetcher", () => { + let ctx: TestContext = createTestContext(); + const retentionPolicy = new Map, number>([ + [ParsableMintInfo, 1000], + [ParsableTokenAccountInfo, 1000], + ]); + const testMints: PublicKey[] = []; + + beforeAll(async () => { + await requestAirdrop(ctx); + for (let i = 0; i < 10; i++) { + testMints.push(await createNewMint(ctx, TOKEN_PROGRAM_ID)); + } + }); + + beforeEach(() => { + ctx = createTestContext(); + jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + // jest.resetAllMocks doesn't work (I guess that jest.spyOn rewrite prototype of Connection) + jest.restoreAllMocks(); + }); + + describe("getAccount", () => { + it("fetch brand new account equals on-chain", async () => { + const mintKey = testMints[0]; + + const expected = await getMint(ctx.connection, mintKey); + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + const cached = await fetcher.getAccount(mintKey, ParsableMintInfo); + + expect(spy).toBeCalledTimes(1); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("returns cached value within retention window", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = Date.now(); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, undefined, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + undefined, + now + retention, + ); + + expect(spy).toBeCalledTimes(1); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("fetch new value when call is outside of retention window", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = 32523523523; + const retention = retentionPolicy.get(ParsableMintInfo)!; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, undefined, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + undefined, + now + retention + 1, + ); + + expect(spy).toBeCalledTimes(2); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("getAccount - return cache value when call does not exceed custom ttl", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const ttl = 50; + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, { maxAge: ttl }, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + { maxAge: ttl }, + now + ttl, + ); + + expect(spy).toBeCalledTimes(1); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("fetch new value when call exceed custom ttl", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const ttl = 50; + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, { maxAge: ttl }, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + { maxAge: ttl }, + now + ttl + 1, + ); + + expect(spy).toBeCalledTimes(2); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("fetch new value when call ttl === 0", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, new Map()); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, { maxAge: 0 }, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + { maxAge: 0 }, + now + 1, + ); + + expect(spy).toBeCalledTimes(2); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("fetch new value when call retention === 0", async () => { + const mintKey = testMints[0]; + const expected = await getMint(ctx.connection, mintKey); + const now = 32523523523; + const retentionPolicy = new Map< + ParsableEntity, + number + >([[ParsableMintInfo, 0]]); + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount(mintKey, ParsableMintInfo, undefined, now); + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + undefined, + now + 1, + ); + + expect(spy).toBeCalledTimes(2); + expect(cached).toBeDefined(); + expectMintEquals(cached!, expected); + }); + + it("fetching invalid account returns null", async () => { + const mintKey = PublicKey.default; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const cached = await fetcher.getAccount( + mintKey, + ParsableMintInfo, + undefined, + now, + ); + + expect(cached).toBeNull(); + }); + + it("fetching valid account but invalid account type returns null", async () => { + const mintKey = testMints[0]; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const cached = await fetcher.getAccount( + mintKey, + ParsableTokenAccountInfo, + undefined, + now, + ); + + expect(cached).toBeNull(); + }); + + it("fetching null-cached accounts will respect ttl", async () => { + const mintKey = testMints[0]; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getAccountInfo"); + await fetcher.getAccount( + mintKey, + ParsableTokenAccountInfo, + undefined, + now, + ); + const cached = await fetcher.getAccount( + mintKey, + ParsableTokenAccountInfo, + undefined, + now + 5, + ); + + expect(spy).toBeCalledTimes(1); + expect(cached).toBeNull(); + }); + }); + + describe("getAccounts", () => { + let expectedMintInfos: Mint[] = []; + + beforeAll(async () => { + for (const mint of testMints) { + expectedMintInfos.push(await getMint(ctx.connection, mint)); + } + }); + + it("nothing cached, fetching all values", async () => { + const mintKeys = testMints; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const resultMap = await fetcher.getAccounts( + mintKeys, + ParsableMintInfo, + undefined, + now, + ); + + expect(spy).toBeCalledTimes(1); + + Array.from(resultMap.values()).forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("all are cached, fetching all values will not call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts(mintKeys, ParsableMintInfo, undefined, now); + const resultMap = await fetcher.getAccounts( + mintKeys, + ParsableMintInfo, + undefined, + now + retention, + ); + expect(spy).toBeCalledTimes(1); + Array.from(resultMap.values()).forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("all are cached but expired, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts(mintKeys, ParsableMintInfo, undefined, now); + const resultMap = await fetcher.getAccounts( + mintKeys, + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(2); + Array.from(resultMap.values()).forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + const resultMap = await fetcher.getAccounts( + mintKeys, + ParsableMintInfo, + undefined, + now + retention, + ); + expect(spy).toBeCalledTimes(2); + Array.from(resultMap.values()).forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, some expired, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + await fetcher.getAccounts( + [testMints[2], testMints[3]], + ParsableMintInfo, + undefined, + now + 5, + ); + const resultMap = await fetcher.getAccounts( + mintKeys, + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(3); + Array.from(resultMap.values()).forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, some expired, some invalid", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + await fetcher.getAccounts( + [testMints[2], testMints[3], PublicKey.default], + ParsableMintInfo, + undefined, + now + 5, + ); + const resultMap = await fetcher.getAccounts( + [...mintKeys, PublicKey.default], + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(3); + Array.from(resultMap.values()).forEach((value, index) => { + if (index <= mintKeys.length - 1) { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + } else { + // Expect the last value, which is invalid, to be null + expect(value).toBeNull(); + } + }); + }); + }); + + describe("getAccountsAsArray", () => { + let expectedMintInfos: Mint[] = []; + + beforeAll(async () => { + for (const mint of testMints) { + expectedMintInfos.push(await getMint(ctx.connection, mint)); + } + }); + + it("nothing cached, fetching all values", async () => { + const mintKeys = testMints; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const resultArray = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now, + ); + + expect(spy).toBeCalledTimes(1); + + resultArray.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("duplicated values are shown", async () => { + const mintKeys = [...testMints, ...testMints]; + const expected = [...expectedMintInfos, ...expectedMintInfos]; + const now = 32523523523; + + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const resultArray = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now, + ); + + expect(spy).toBeCalledTimes(1); + + resultArray.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expected[index]); + }); + }); + + it("all are cached, fetching all values will not call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts(mintKeys, ParsableMintInfo, undefined, now); + const result = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now + retention, + ); + expect(spy).toBeCalledTimes(1); + result.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("all are cached but expired, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts(mintKeys, ParsableMintInfo, undefined, now); + const result = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(2); + result.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + const result = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now + retention, + ); + expect(spy).toBeCalledTimes(2); + result.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, some expired, fetching all values will call for update", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + await fetcher.getAccounts( + [testMints[2], testMints[3]], + ParsableMintInfo, + undefined, + now + 5, + ); + const result = await fetcher.getAccountsAsArray( + mintKeys, + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(3); + result.forEach((value, index) => { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + }); + }); + + it("some are cached, some expired, some invalid", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const retention = retentionPolicy.get(ParsableMintInfo)!; + + await fetcher.getAccounts( + [testMints[0], testMints[1]], + ParsableMintInfo, + undefined, + now, + ); + await fetcher.getAccounts( + [testMints[2], testMints[3], PublicKey.default], + ParsableMintInfo, + undefined, + now + 5, + ); + const result = await fetcher.getAccountsAsArray( + [...mintKeys, PublicKey.default], + ParsableMintInfo, + undefined, + now + retention + 1, + ); + expect(spy).toBeCalledTimes(3); + result.forEach((value, index) => { + if (index <= mintKeys.length - 1) { + expect(value).toBeDefined(); + expectMintEquals(value!, expectedMintInfos[index]); + } else { + // Expect the last value, which is invalid, to be null + expect(value).toBeNull(); + } + }); + }); + }); + + describe("refreshAll", () => { + it("refresh all updates all keys", async () => { + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const now = 32523523523; + + // Populate cache + await fetcher.getAccounts(testMints, ParsableMintInfo, undefined, now); + + const spy = jest.spyOn(ctx.connection, "getMultipleAccountsInfo"); + const renewNow = now + 500000; + await fetcher.refreshAll(renewNow); + expect(spy).toBeCalledTimes(1); + fetcher.cache.forEach((value, _) => { + expect(value.fetchedAt).toEqual(renewNow); + }); + }); + }); + + describe.only("populateAccounts", () => { + let expectedMintInfos: Mint[] = []; + + beforeAll(async () => { + for (const mint of testMints) { + expectedMintInfos.push(await getMint(ctx.connection, mint)); + } + }); + + it("populateAccounts updates all keys from empty state", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const other = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + const testSet = [mintKeys[0], mintKeys[1], mintKeys[2]]; + const otherFetched = await other.getAccounts( + testSet, + ParsableMintInfo, + undefined, + now, + ); + + // Populate the fetcher with prefetched accounts and fetch from the fetcher to see if the cached values are set + fetcher.populateAccounts(otherFetched, ParsableMintInfo, now); + const results = await fetcher.getAccountsAsArray( + testSet, + ParsableMintInfo, + { + maxAge: Number.POSITIVE_INFINITY, + }, + now + 5, + ); + + results.forEach((value, index) => { + expectMintEquals(value!, expectedMintInfos[index]); + }); + fetcher.cache.forEach((value, _) => { + expect(value.fetchedAt).toEqual(now); + }); + }); + + it("populateAccounts updates all keys from non-empty state", async () => { + const mintKeys = testMints; + const now = 32523523523; + const fetcher = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + const other = new SimpleAccountFetcher(ctx.connection, retentionPolicy); + + await fetcher.getAccount( + mintKeys[0], + ParsableMintInfo, + undefined, + now - 5, + ); + const testSet = [mintKeys[0], mintKeys[1], mintKeys[2]]; + const otherFetched = await other.getAccounts( + testSet, + ParsableMintInfo, + undefined, + now, + ); + + expect(fetcher.cache.size).toEqual(1); + fetcher.cache.forEach((value, _) => { + expect(value.fetchedAt).toEqual(now - 5); + }); + + // Populate the fetcher with prefetched accounts and fetch from the fetcher to see if the cached values are set + fetcher.populateAccounts(otherFetched, ParsableMintInfo, now); + const results = await fetcher.getAccountsAsArray( + testSet, + ParsableMintInfo, + { + maxAge: Number.POSITIVE_INFINITY, + }, + now + 5, + ); + + results.forEach((value, index) => { + expectMintEquals(value!, expectedMintInfos[index]); + }); + + fetcher.cache.forEach((value, _) => { + expect(value.fetchedAt).toEqual(now); + }); + }); + }); +}); diff --git a/legacy-sdk/common/tests/web3/transactions/constants.test.ts b/legacy-sdk/common/tests/web3/transactions/constants.test.ts new file mode 100644 index 000000000..1066d9817 --- /dev/null +++ b/legacy-sdk/common/tests/web3/transactions/constants.test.ts @@ -0,0 +1,15 @@ +import { PACKET_DATA_SIZE } from "@solana/web3.js"; +import { TX_SIZE_LIMIT, TX_BASE64_ENCODED_SIZE_LIMIT } from "../../../src/web3"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("transactions-constants", () => { + it("TX_SIZE_LIMIT", async () => { + expect(TX_SIZE_LIMIT).toEqual(1232); + expect(TX_SIZE_LIMIT).toEqual(PACKET_DATA_SIZE); + }); + + it("TX_BASE64_ENCODED_SIZE_LIMIT", async () => { + expect(TX_BASE64_ENCODED_SIZE_LIMIT).toEqual(1644); + }); +}); diff --git a/legacy-sdk/common/tests/web3/transactions/transactions-builder.test.ts b/legacy-sdk/common/tests/web3/transactions/transactions-builder.test.ts new file mode 100644 index 000000000..88bb8e561 --- /dev/null +++ b/legacy-sdk/common/tests/web3/transactions/transactions-builder.test.ts @@ -0,0 +1,122 @@ +import type { TransactionInstruction } from "@solana/web3.js"; +import { SystemProgram, Keypair } from "@solana/web3.js"; +import { + defaultTransactionBuilderOptions, + isVersionedTransaction, + MEASUREMENT_BLOCKHASH, + TransactionBuilder, +} from "../../../src/web3"; +import { createTestContext } from "../../test-context"; + +jest.setTimeout(100 * 1000 /* ms */); + +describe("transactions-builder", () => { + const ctx = createTestContext(); + + describe("txnSize", () => { + const buildTransactionBuilder = ( + transferIxNum: number, + version: "legacy" | number, + ) => { + const { wallet, connection } = ctx; + + const ixs: TransactionInstruction[] = []; + for (let i = 0; i < transferIxNum; i++) { + ixs.push( + SystemProgram.transfer({ + programId: SystemProgram.programId, + fromPubkey: wallet.publicKey, + lamports: 10_000_000, + toPubkey: Keypair.generate().publicKey, + }), + ); + } + + const builder = new TransactionBuilder(connection, wallet, { + ...defaultTransactionBuilderOptions, + defaultBuildOption: { + maxSupportedTransactionVersion: version, + latestBlockhash: MEASUREMENT_BLOCKHASH, + blockhashCommitment: "confirmed", + }, + }); + + builder.addInstruction({ + instructions: ixs, + cleanupInstructions: [], + signers: [], + }); + + return builder; + }; + + it("empty", async () => { + const { wallet, connection } = ctx; + const builder = new TransactionBuilder(connection, wallet); + + const size = builder.txnSize(); + expect(size).toEqual(0); + }); + + it("legacy: size < PACKET_DATA_SIZE", async () => { + const builder = buildTransactionBuilder(15, "legacy"); + + // should be legacy + const transaction = await builder.build(); + expect(isVersionedTransaction(transaction.transaction)).toBeFalsy(); + + const size = builder.txnSize(); + expect(size).toEqual(901); + }); + + it("legacy: size > PACKET_DATA_SIZE", async () => { + const builder = buildTransactionBuilder(22, "legacy"); + + // should be legacy + const transaction = await builder.build(); + expect(isVersionedTransaction(transaction.transaction)).toBeFalsy(); + + // logical size: 1244 > PACKET_DATA_SIZE + expect(() => builder.txnSize()).toThrow( + /Unable to measure transaction size/, + ); + }); + + it("v0: size < PACKET_DATA_SIZE", async () => { + const builder = buildTransactionBuilder(15, 0); + + // should be versioned + const transaction = await builder.build(); + expect(isVersionedTransaction(transaction.transaction)).toBeTruthy(); + + const size = builder.txnSize(); + expect(size).toEqual(903); + }); + + it("v0: size > PACKET_DATA_SIZE", async () => { + const builder = buildTransactionBuilder(22, 0); + + // should be versioned + const transaction = await builder.build(); + expect(isVersionedTransaction(transaction.transaction)).toBeTruthy(); + + // logical size: 1246 > PACKET_DATA_SIZE + expect(() => builder.txnSize()).toThrow( + /Unable to measure transaction size/, + ); + }); + + it("v0: size >> PACKET_DATA_SIZE", async () => { + const builder = buildTransactionBuilder(42, 0); + + // should be versioned + const transaction = await builder.build(); + expect(isVersionedTransaction(transaction.transaction)).toBeTruthy(); + + // logical size: 2226 >> PACKET_DATA_SIZE + expect(() => builder.txnSize()).toThrow( + /Unable to measure transaction size/, + ); + }); + }); +}); diff --git a/legacy-sdk/common/tsconfig.json b/legacy-sdk/common/tsconfig.json new file mode 100644 index 000000000..abe69acc6 --- /dev/null +++ b/legacy-sdk/common/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist" + }, + "include": ["./src/**/*.ts"] +} diff --git a/legacy-sdk/integration/package.json b/legacy-sdk/integration/package.json new file mode 100644 index 000000000..1a474a313 --- /dev/null +++ b/legacy-sdk/integration/package.json @@ -0,0 +1,12 @@ +{ + "name": "@orca-so/whirlpools-sdk-integration", + "version": "0.1.0", + "devDependencies": { + "@orca-so/common-sdk": "*", + "@orca-so/whirlpools-sdk": "*" + }, + "scripts": { + "build": "anchor build", + "test": "anchor test --skip-build" + } +} diff --git a/legacy-sdk/whirlpool/package.json b/legacy-sdk/whirlpool/package.json index efcf4ca1c..c761f7589 100644 --- a/legacy-sdk/whirlpool/package.json +++ b/legacy-sdk/whirlpool/package.json @@ -29,7 +29,7 @@ "scripts": { "build": "mkdir -p ./src/artifacts && cp -f ../../target/idl/whirlpool.json ./src/artifacts/whirlpool.json && cp -f ../../target/types/whirlpool.ts ./src/artifacts/whirlpool.ts && tsc", "clean": "rimraf dist", - "test": "anchor test --skip-build" + "prepublishOnly": "cd ../.. && yarn build legacy-sdk/whirlpool" }, "files": [ "dist", diff --git a/yarn.lock b/yarn.lock index 30fdc4755..c9fca184e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4142,6 +4142,22 @@ __metadata: languageName: node linkType: hard +"@orca-so/common-sdk@npm:*, @orca-so/common-sdk@workspace:legacy-sdk/common": + version: 0.0.0-use.local + resolution: "@orca-so/common-sdk@workspace:legacy-sdk/common" + dependencies: + "@solana/spl-token": "npm:^0.4.1" + "@solana/web3.js": "npm:^1.90.0" + decimal.js: "npm:^10.4.3" + tiny-invariant: "npm:^1.3.1" + typescript: "npm:^5.6.3" + peerDependencies: + "@solana/spl-token": ^0.4.1 + "@solana/web3.js": ^1.90.0 + decimal.js: ^10.4.3 + languageName: unknown + linkType: soft + "@orca-so/common-sdk@npm:0.6.4": version: 0.6.4 resolution: "@orca-so/common-sdk@npm:0.6.4" @@ -4155,18 +4171,6 @@ __metadata: languageName: node linkType: hard -"@orca-so/orca-sdk@npm:0.2.0": - version: 0.2.0 - resolution: "@orca-so/orca-sdk@npm:0.2.0" - peerDependencies: - "@orca-so/common-sdk": ^0.4.0 - "@solana/web3.js": ^1.74.0 - axios: ^1.6.5 - decimal.js: ^10.3.1 - checksum: 10c0/654d955c7a3295a768c294e5db571585d8951f8ce2aad151e092867e032a8f663ec7bb3b83ec9ca8581c323e08be4fed566dc1a38e311858bea61fa1c567b5ad - languageName: node - linkType: hard - "@orca-so/whirlpools-client@npm:*, @orca-so/whirlpools-client@workspace:ts-sdk/client": version: 0.0.0-use.local resolution: "@orca-so/whirlpools-client@workspace:ts-sdk/client" @@ -4367,8 +4371,7 @@ __metadata: resolution: "@orca-so/whirlpools-sdk-cli@workspace:legacy-sdk/cli" dependencies: "@coral-xyz/anchor": "npm:0.29.0" - "@orca-so/common-sdk": "npm:0.6.4" - "@orca-so/orca-sdk": "npm:0.2.0" + "@orca-so/common-sdk": "npm:*" "@orca-so/whirlpools-sdk": "npm:*" "@solana/spl-token": "npm:0.4.1" "@solana/web3.js": "npm:^1.90.0" @@ -4383,6 +4386,15 @@ __metadata: languageName: unknown linkType: soft +"@orca-so/whirlpools-sdk-integration@workspace:legacy-sdk/integration": + version: 0.0.0-use.local + resolution: "@orca-so/whirlpools-sdk-integration@workspace:legacy-sdk/integration" + dependencies: + "@orca-so/common-sdk": "npm:*" + "@orca-so/whirlpools-sdk": "npm:*" + languageName: unknown + linkType: soft + "@orca-so/whirlpools-sdk@npm:*, @orca-so/whirlpools-sdk@workspace:legacy-sdk/whirlpool": version: 0.0.0-use.local resolution: "@orca-so/whirlpools-sdk@workspace:legacy-sdk/whirlpool" @@ -5321,7 +5333,7 @@ __metadata: languageName: node linkType: hard -"@solana/spl-token@npm:^0.4.8": +"@solana/spl-token@npm:^0.4.1, @solana/spl-token@npm:^0.4.8": version: 0.4.9 resolution: "@solana/spl-token@npm:0.4.9" dependencies: @@ -17511,7 +17523,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.7.2": +"typescript@npm:^5.6.3, typescript@npm:^5.7.2": version: 5.7.2 resolution: "typescript@npm:5.7.2" bin: @@ -17521,7 +17533,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.7.2#optional!builtin": +"typescript@patch:typescript@npm%3A^5.6.3#optional!builtin, typescript@patch:typescript@npm%3A^5.7.2#optional!builtin": version: 5.7.2 resolution: "typescript@patch:typescript@npm%3A5.7.2#optional!builtin::version=5.7.2&hash=cef18b" bin: