diff --git a/packages/common-sdk/package.json b/packages/common-sdk/package.json index 9d4aa5e..ca87420 100644 --- a/packages/common-sdk/package.json +++ b/packages/common-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@orca-so/common-sdk", - "version": "0.3.1", + "version": "0.3.2", "description": "Common Typescript components across Orca", "repository": "https://github.com/orca-so/orca-sdks", "author": "Orca Foundation", @@ -23,11 +23,13 @@ "jest": "^29.5.0", "prettier": "^2.3.2", "process": "^0.11.10", + "rimraf": "^4.1.2", "ts-jest": "^29.1.0", "typescript": "^4.5.5" }, "scripts": { - "build": "tsc -p src", + "build": "rimraf dist && tsc -p src", + "clean": "rimraf dist", "watch": "tsc -w -p src", "prepublishOnly": "yarn build", "prettier-format": "prettier --config .prettierrc 'src/**/*.ts' --write", diff --git a/packages/common-sdk/src/web3/ata-util.ts b/packages/common-sdk/src/web3/ata-util.ts index c0afc61..f8358de 100644 --- a/packages/common-sdk/src/web3/ata-util.ts +++ b/packages/common-sdk/src/web3/ata-util.ts @@ -8,7 +8,7 @@ import { Connection, PublicKey } from "@solana/web3.js"; import BN from "bn.js"; import { ZERO } from "../math"; import { ParsableTokenAccountInfo, getMultipleParsedAccounts } from "./network"; -import { ResolvedTokenAddressInstruction, TokenUtil } from "./token-util"; +import { ResolvedTokenAddressInstruction, TokenUtil, WrappedSolAccountCreateMethod } from "./token-util"; import { EMPTY_INSTRUCTION } from "./transactions/types"; /** @@ -22,6 +22,8 @@ import { EMPTY_INSTRUCTION } from "./transactions/types"; * @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( @@ -31,7 +33,9 @@ export async function resolveOrCreateATA( getAccountRentExempt: () => Promise, wrappedSolAmountIn = ZERO, payer = ownerAddress, - modeIdempotent: boolean = false + modeIdempotent: boolean = false, + allowPDAOwnerAddress: boolean = false, + wrappedSolAccountCreateMethod: WrappedSolAccountCreateMethod = "keypair", ): Promise { const instructions = await resolveOrCreateATAs( connection, @@ -39,7 +43,9 @@ export async function resolveOrCreateATA( [{ tokenMint, wrappedSolAmountIn }], getAccountRentExempt, payer, - modeIdempotent + modeIdempotent, + allowPDAOwnerAddress, + wrappedSolAccountCreateMethod, ); return instructions[0]!; } @@ -60,6 +66,8 @@ type ResolvedTokenAddressRequest = { * @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( @@ -68,7 +76,9 @@ export async function resolveOrCreateATAs( requests: ResolvedTokenAddressRequest[], getAccountRentExempt: () => Promise, payer = ownerAddress, - modeIdempotent: boolean = false + 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)); @@ -80,7 +90,7 @@ export async function resolveOrCreateATAs( let instructionMap: { [tokenMint: string]: ResolvedTokenAddressInstruction } = {}; if (nonNativeMints.length > 0) { const nonNativeAddresses = nonNativeMints.map(({ tokenMint }) => - getAssociatedTokenAddressSync(tokenMint, ownerAddress) + getAssociatedTokenAddressSync(tokenMint, ownerAddress, allowPDAOwnerAddress) ); const tokenAccounts = await getMultipleParsedAccounts( @@ -132,7 +142,10 @@ export async function resolveOrCreateATAs( instructionMap[NATIVE_MINT.toBase58()] = TokenUtil.createWrappedNativeAccountInstruction( ownerAddress, wrappedSolAmountIn, - accountRentExempt + accountRentExempt, + undefined, // use default + undefined, // use default + wrappedSolAccountCreateMethod ); } diff --git a/packages/common-sdk/src/web3/token-util.ts b/packages/common-sdk/src/web3/token-util.ts index 089e6fb..be333f1 100644 --- a/packages/common-sdk/src/web3/token-util.ts +++ b/packages/common-sdk/src/web3/token-util.ts @@ -8,6 +8,7 @@ import { getAssociatedTokenAddressSync, } from "@solana/spl-token"; import { Connection, Keypair, PublicKey, SystemProgram } from "@solana/web3.js"; +import { sha256 } from '@noble/hashes/sha256'; import BN from "bn.js"; import invariant from "tiny-invariant"; import { ZERO } from "../math"; @@ -19,6 +20,11 @@ export type ResolvedTokenAddressInstruction = { address: PublicKey; } & Instruction; +/** + * @category Util + */ +export type WrappedSolAccountCreateMethod = "keypair" | "withSeed"; + /** * @category Util */ @@ -29,50 +35,28 @@ export class TokenUtil { /** * Create an ix to send a native-mint and unwrap it to the user's wallet. - * @param owner - * @param amountIn - * @param rentExemptLamports - * @param payer - * @param unwrapDestination + * @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 */ - static createWrappedNativeAccountInstruction( + public static createWrappedNativeAccountInstruction( owner: PublicKey, amountIn: BN, rentExemptLamports: number, payer?: PublicKey, - unwrapDestination?: PublicKey + unwrapDestination?: PublicKey, + createAccountMethod: WrappedSolAccountCreateMethod = "keypair", ): ResolvedTokenAddressInstruction { const payerKey = payer ?? owner; - const tempAccount = new Keypair(); const unwrapDestinationKey = unwrapDestination ?? payer ?? owner; - 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, - instructions: [createAccountInstruction, initAccountInstruction], - cleanupInstructions: [closeWSOLAccountInstruction], - signers: [tempAccount], - }; + return createAccountMethod === "keypair" + ? createWrappedNativeAccountInstructionWithKeypair(owner, amountIn, rentExemptLamports, payerKey, unwrapDestinationKey) + : createWrappedNativeAccountInstructionWithSeed(owner, amountIn, rentExemptLamports, payerKey, unwrapDestinationKey); } /** @@ -88,6 +72,7 @@ export class TokenUtil { * @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( @@ -98,7 +83,8 @@ export class TokenUtil { tokenDecimals: number, amount: BN, getAccountRentExempt: () => Promise, - payer?: PublicKey + payer?: PublicKey, + allowPDASourceWallet: boolean = false ): Promise { invariant(!amount.eq(ZERO), "SendToken transaction must send more than 0 tokens."); @@ -116,7 +102,7 @@ export class TokenUtil { }; } - const sourceTokenAccount = getAssociatedTokenAddressSync(tokenMint, sourceWallet); + const sourceTokenAccount = getAssociatedTokenAddressSync(tokenMint, sourceWallet, allowPDASourceWallet); const { address: destinationTokenAccount, ...destinationAtaIx } = await resolveOrCreateATA( connection, destinationWallet, @@ -142,3 +128,95 @@ export class TokenUtil { }; } } + +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, + 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, + instructions: [createAccountInstruction, initAccountInstruction], + cleanupInstructions: [closeWSOLAccountInstruction], + signers: [], + }; +} diff --git a/packages/common-sdk/tests/web3/ata-util.test.ts b/packages/common-sdk/tests/web3/ata-util.test.ts index 2863116..b53063d 100644 --- a/packages/common-sdk/tests/web3/ata-util.test.ts +++ b/packages/common-sdk/tests/web3/ata-util.test.ts @@ -278,4 +278,108 @@ describe("ata-util", () => { ); await expect(postOwnerChangedPromise).rejects.toThrow(/ATA with change of ownership detected/); }); + + it("resolveOrCreateATA, allowPDAOwnerAddress = false", async () => { + const mint = await createNewMint(ctx); + + const pda = getAssociatedTokenAddressSync(mint, wallet.publicKey); // 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: any) { + expect(e.name).toMatch("TokenOwnerOffCurveError"); + } + }); + + it("resolveOrCreateATA, allowPDAOwnerAddress = true", async () => { + const mint = await createNewMint(ctx); + + const pda = getAssociatedTokenAddressSync(mint, wallet.publicKey); // ATA is one of PDAs + const allowPDAOwnerAddress = true; + + try { + await resolveOrCreateATA( + connection, + pda, + mint, + () => connection.getMinimumBalanceForRentExemption(AccountLayout.span), + ZERO, + wallet.publicKey, + false, + allowPDAOwnerAddress + ); + } catch (e: any) { + fail("should be failed"); + } + }); + + 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/packages/common-sdk/tests/web3/network/simple-fetcher-impl.test.ts b/packages/common-sdk/tests/web3/network/simple-fetcher-impl.test.ts index b11e297..ef218e2 100644 --- a/packages/common-sdk/tests/web3/network/simple-fetcher-impl.test.ts +++ b/packages/common-sdk/tests/web3/network/simple-fetcher-impl.test.ts @@ -33,7 +33,8 @@ describe("simple-account-fetcher", () => { }); afterEach(() => { - jest.resetAllMocks(); + // jest.resetAllMocks doesn't work (I guess that jest.spyOn rewrite prototype of Connection) + jest.restoreAllMocks(); }); describe("getAccount", () => { diff --git a/yarn.lock b/yarn.lock index b93b004..b7c555a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1498,6 +1498,16 @@ decimal.js "^10.3.1" tiny-invariant "^1.2.0" +"@orca-so/common-sdk@^0.3.0", "@orca-so/common-sdk@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@orca-so/common-sdk/-/common-sdk-0.3.1.tgz#939232636b8888237981fad328ddb07a81decfa1" + integrity sha512-hk4wPdEBlugfJ2vBnap0P8bH/h/OX109s+NMPZT3CRmQX2IRaal/KBEHT/Gi3OuCG0JSPDvtiFKtXzHnScCt6A== + dependencies: + "@solana/spl-token" "^0.3.8" + "@solana/web3.js" "^1.75.0" + decimal.js "^10.3.1" + tiny-invariant "^1.2.0" + "@orca-so/token-sdk@^0.1.5": version "0.1.5" resolved "https://registry.yarnpkg.com/@orca-so/token-sdk/-/token-sdk-0.1.5.tgz#a27c99e4ebd3dfc3fe3dbb8243b12911a717dfb2"