diff --git a/Anchor.toml b/Anchor.toml index f921fcb6c..62a2c3efe 100644 --- a/Anchor.toml +++ b/Anchor.toml @@ -53,6 +53,7 @@ addressToPublicKey = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/addressT publicKeyToAddress = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/publicKeyToAddress.ts" findFillStatusPdaFromEvent = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/findFillStatusPdaFromEvent.ts" findFillStatusFromFillStatusPda = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/findFillStatusFromFillStatusPda.ts" +nativeDeposit = "NODE_NO_WARNINGS=1 yarn run ts-node ./scripts/svm/nativeDeposit.ts" [test.validator] url = "https://api.mainnet-beta.solana.com" diff --git a/scripts/svm/nativeDeposit.ts b/scripts/svm/nativeDeposit.ts new file mode 100644 index 000000000..9ff23601f --- /dev/null +++ b/scripts/svm/nativeDeposit.ts @@ -0,0 +1,183 @@ +// This script is used to initiate a native token Solana deposit. useful in testing. + +import * as anchor from "@coral-xyz/anchor"; +import { AnchorProvider, BN } from "@coral-xyz/anchor"; +import { + ASSOCIATED_TOKEN_PROGRAM_ID, + NATIVE_MINT, + TOKEN_PROGRAM_ID, + createApproveCheckedInstruction, + createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, + createSyncNativeInstruction, + getAssociatedTokenAddressSync, + getMinimumBalanceForRentExemptAccount, + getMint, +} from "@solana/spl-token"; +import { + PublicKey, + Transaction, + sendAndConfirmTransaction, + TransactionInstruction, + SystemProgram, +} from "@solana/web3.js"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { getSpokePoolProgram, SOLANA_SPOKE_STATE_SEED } from "../../src/svm/web3-v1"; + +// Set up the provider +const provider = AnchorProvider.env(); +anchor.setProvider(provider); +const program = getSpokePoolProgram(provider); +const programId = program.programId; +console.log("SVM-Spoke Program ID:", programId.toString()); + +// Parse arguments +const argv = yargs(hideBin(process.argv)) + .option("recipient", { type: "string", demandOption: true, describe: "Recipient public key" }) + .option("outputToken", { type: "string", demandOption: true, describe: "Output token public key" }) + .option("inputAmount", { type: "number", demandOption: true, describe: "Input amount" }) + .option("outputAmount", { type: "number", demandOption: true, describe: "Output amount" }) + .option("destinationChainId", { type: "string", demandOption: true, describe: "Destination chain ID" }) + .option("integratorId", { type: "string", demandOption: false, describe: "integrator ID" }).argv; + +async function nativeDeposit(): Promise { + const resolvedArgv = await argv; + const seed = SOLANA_SPOKE_STATE_SEED; + const recipient = new PublicKey(resolvedArgv.recipient); + const inputToken = NATIVE_MINT; + const outputToken = new PublicKey(resolvedArgv.outputToken); + const inputAmount = new BN(resolvedArgv.inputAmount); + const outputAmount = new BN(resolvedArgv.outputAmount); + const destinationChainId = new BN(resolvedArgv.destinationChainId); + const exclusiveRelayer = PublicKey.default; + const quoteTimestamp = Math.floor(Date.now() / 1000) - 1; + const fillDeadline = quoteTimestamp + 3600; // 1 hour from now + const exclusivityDeadline = 0; + const message = Buffer.from([]); // Convert to Buffer + const integratorId = resolvedArgv.integratorId || ""; + // Define the state account PDA + const [statePda, _] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), seed.toArrayLike(Buffer, "le", 8)], + programId + ); + + // Define the signer (replace with your actual signer) + const signer = (provider.wallet as anchor.Wallet).payer; + + // Find ATA for the input token to be stored by state (vault). This was created when the route was enabled. + const vault = getAssociatedTokenAddressSync( + inputToken, + statePda, + true, + TOKEN_PROGRAM_ID, + ASSOCIATED_TOKEN_PROGRAM_ID + ); + + const userTokenAccount = getAssociatedTokenAddressSync(inputToken, signer.publicKey); + const userTokenAccountInfo = await provider.connection.getAccountInfo(userTokenAccount); + const existingTokenAccount = userTokenAccountInfo !== null && userTokenAccountInfo.owner.equals(TOKEN_PROGRAM_ID); + + console.log("Depositing V3..."); + console.table([ + { property: "seed", value: seed.toString() }, + { property: "recipient", value: recipient.toString() }, + { property: "inputToken", value: inputToken.toString() }, + { property: "outputToken", value: outputToken.toString() }, + { property: "inputAmount", value: inputAmount.toString() }, + { property: "outputAmount", value: outputAmount.toString() }, + { property: "destinationChainId", value: destinationChainId.toString() }, + { property: "quoteTimestamp", value: quoteTimestamp.toString() }, + { property: "fillDeadline", value: fillDeadline.toString() }, + { property: "exclusivityDeadline", value: exclusivityDeadline.toString() }, + { property: "message", value: message.toString("hex") }, + { property: "integratorId", value: integratorId }, + { property: "programId", value: programId.toString() }, + { property: "providerPublicKey", value: provider.wallet.publicKey.toString() }, + { property: "statePda", value: statePda.toString() }, + { property: "vault", value: vault.toString() }, + { property: "userTokenAccount", value: userTokenAccount.toString() }, + { property: "existingTokenAccount", value: existingTokenAccount }, + ]); + + const tokenDecimals = (await getMint(provider.connection, inputToken, undefined, TOKEN_PROGRAM_ID)).decimals; + + // Will need to add rent exemption to the deposit amount if the user token account does not exist. + const rentExempt = existingTokenAccount ? 0 : await getMinimumBalanceForRentExemptAccount(provider.connection); + const transferIx = SystemProgram.transfer({ + fromPubkey: signer.publicKey, + toPubkey: userTokenAccount, + lamports: BigInt(inputAmount.toString()) + BigInt(rentExempt), + }); + + // Create wSOL user account if it doesn't exist, otherwise sync its native balance. + const syncOrCreateIx = existingTokenAccount + ? createSyncNativeInstruction(userTokenAccount) + : createAssociatedTokenAccountIdempotentInstruction( + signer.publicKey, + userTokenAccount, + signer.publicKey, + inputToken + ); + + // Close the user token account if it did not exist before. + const lastIxs = existingTokenAccount + ? [] + : [createCloseAccountInstruction(userTokenAccount, signer.publicKey, signer.publicKey)]; + + // Delegate state PDA to pull depositor tokens. + const approveIx = await createApproveCheckedInstruction( + userTokenAccount, + inputToken, + statePda, + signer.publicKey, + BigInt(inputAmount.toString()), + tokenDecimals, + undefined, + TOKEN_PROGRAM_ID + ); + + const depositIx = await ( + program.methods.deposit( + signer.publicKey, + recipient, + inputToken, + outputToken, + inputAmount, + outputAmount, + destinationChainId, + exclusiveRelayer, + quoteTimestamp, + fillDeadline, + exclusivityDeadline, + message + ) as any + ) + .accounts({ + state: statePda, + signer: signer.publicKey, + userTokenAccount, + vault: vault, + tokenProgram: TOKEN_PROGRAM_ID, + mint: inputToken, + }) + .instruction(); + + // Create the deposit transaction + const depositTx = new Transaction().add(transferIx, syncOrCreateIx, approveIx, depositIx, ...lastIxs); + + if (integratorId !== "") { + const MemoIx = new TransactionInstruction({ + keys: [{ pubkey: signer.publicKey, isSigner: true, isWritable: true }], + data: Buffer.from(integratorId, "utf-8"), + programId: new PublicKey("MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr"), // Memo program ID + }); + depositTx.add(MemoIx); + } + + const tx = await sendAndConfirmTransaction(provider.connection, depositTx, [signer]); + console.log("Transaction signature:", tx); +} + +// Run the nativeDeposit function +nativeDeposit(); diff --git a/test/svm/SvmSpoke.Deposit.ts b/test/svm/SvmSpoke.Deposit.ts index c99a12430..8e597e1c3 100644 --- a/test/svm/SvmSpoke.Deposit.ts +++ b/test/svm/SvmSpoke.Deposit.ts @@ -12,19 +12,24 @@ import { pipe, } from "@solana/kit"; import { - ASSOCIATED_TOKEN_PROGRAM_ID, ExtensionType, + NATIVE_MINT, TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID, createApproveCheckedInstruction, + createAssociatedTokenAccountIdempotentInstruction, + createCloseAccountInstruction, createEnableCpiGuardInstruction, createMint, createReallocateInstruction, + createSyncNativeInstruction, getAccount, + getAssociatedTokenAddressSync, + getMinimumBalanceForRentExemptAccount, getOrCreateAssociatedTokenAccount, mintTo, } from "@solana/spl-token"; -import { Keypair, PublicKey, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; +import { Keypair, PublicKey, SystemProgram, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import { BigNumber, ethers } from "ethers"; import { SvmSpokeClient } from "../../src/svm"; import { DepositInput } from "../../src/svm/clients/SvmSpoke"; @@ -578,6 +583,118 @@ describe("svm_spoke.deposit", () => { } }); + it("Deposit native token, new token account", async () => { + // Fund depositor account with SOL. + const nativeAmount = 1_000_000_000; // 1 SOL + await connection.requestAirdrop(depositor.publicKey, nativeAmount * 2); // Add buffer for transaction fees. + + // Setup wSOL as the input token. + inputToken = NATIVE_MINT; + const nativeDecimals = 9; + depositorTA = getAssociatedTokenAddressSync(inputToken, depositor.publicKey); + await createVault(); + + // Will need to add rent exemption to the deposit amount, will recover it at the end of the transaction. + const rentExempt = await getMinimumBalanceForRentExemptAccount(connection); + const transferIx = SystemProgram.transfer({ + fromPubkey: depositor.publicKey, + toPubkey: depositorTA, + lamports: BigInt(nativeAmount) + BigInt(rentExempt), + }); + + // Create wSOL user account. + const createIx = createAssociatedTokenAccountIdempotentInstruction( + depositor.publicKey, + depositorTA, + depositor.publicKey, + inputToken + ); + + const approveIx = await createApproveCheckedInstruction( + depositAccounts.depositorTokenAccount, + depositAccounts.mint, + depositAccounts.state, + depositor.publicKey, + BigInt(nativeAmount), + nativeDecimals, + undefined, + tokenProgram + ); + + const nativeDepositData = { ...depositData, inputAmount: new BN(nativeAmount), outputAmount: new BN(nativeAmount) }; + const depositDataValues = Object.values(nativeDepositData) as DepositDataValues; + const depositIx = await program.methods + .deposit(...depositDataValues) + .accounts(depositAccounts) + .instruction(); + + const closeIx = createCloseAccountInstruction(depositorTA, depositor.publicKey, depositor.publicKey); + + const iVaultAmount = (await getAccount(connection, vault, undefined, tokenProgram)).amount; + + const depositTx = new Transaction().add(transferIx, createIx, approveIx, depositIx, closeIx); + const tx = await sendAndConfirmTransaction(connection, depositTx, [depositor]); + + const fVaultAmount = (await getAccount(connection, vault, undefined, tokenProgram)).amount; + assertSE( + fVaultAmount, + iVaultAmount + BigInt(nativeAmount), + "Vault balance should be increased by the deposited amount" + ); + }); + + it("Deposit native token, existing token account", async () => { + // Fund depositor account with SOL. + const nativeAmount = 1_000_000_000; // 1 SOL + await connection.requestAirdrop(depositor.publicKey, nativeAmount * 2); // Add buffer for transaction fees. + + // Setup wSOL as the input token, creating the associated token account for the user. + inputToken = NATIVE_MINT; + const nativeDecimals = 9; + depositorTA = (await getOrCreateAssociatedTokenAccount(connection, payer, inputToken, depositor.publicKey)).address; + await createVault(); + + // Transfer SOL to the user token account. + const transferIx = SystemProgram.transfer({ + fromPubkey: depositor.publicKey, + toPubkey: depositorTA, + lamports: nativeAmount, + }); + + // Sync the user token account with the native balance. + const syncIx = createSyncNativeInstruction(depositorTA); + + const approveIx = await createApproveCheckedInstruction( + depositAccounts.depositorTokenAccount, + depositAccounts.mint, + depositAccounts.state, + depositor.publicKey, + BigInt(nativeAmount), + nativeDecimals, + undefined, + tokenProgram + ); + + const nativeDepositData = { ...depositData, inputAmount: new BN(nativeAmount), outputAmount: new BN(nativeAmount) }; + const depositDataValues = Object.values(nativeDepositData) as DepositDataValues; + const depositIx = await program.methods + .deposit(...depositDataValues) + .accounts(depositAccounts) + .instruction(); + + const iVaultAmount = (await getAccount(connection, vault, undefined, tokenProgram)).amount; + + const depositTx = new Transaction().add(transferIx, syncIx, approveIx, depositIx); + const tx = await sendAndConfirmTransaction(connection, depositTx, [depositor]); + + const fVaultAmount = (await getAccount(connection, vault, undefined, tokenProgram)).amount; + assertSE( + fVaultAmount, + iVaultAmount + BigInt(nativeAmount), + "Vault balance should be increased by the deposited amount" + ); + }); + describe("codama client and solana kit", () => { it("Deposit with with solana kit and codama client", async () => { // typescript is not happy with the depositData object