diff --git a/solana/tests/anchor.test.ts b/solana/tests/anchor.test.ts index 22b7cb793..00004c973 100644 --- a/solana/tests/anchor.test.ts +++ b/solana/tests/anchor.test.ts @@ -13,7 +13,6 @@ import { encoding, serialize, serializePayload, - signSendWait as ssw, } from "@wormhole-foundation/sdk"; import * as testing from "@wormhole-foundation/sdk-definitions/testing"; import { @@ -22,15 +21,21 @@ import { getSolanaSignAndSendSigner, } from "@wormhole-foundation/sdk-solana"; import { SolanaWormholeCore } from "@wormhole-foundation/sdk-solana-core"; -import * as fs from "fs"; -import { DummyTransferHook } from "../ts/idl/1_0_0/ts/dummy_transfer_hook.js"; -import { getTransceiverProgram, IdlVersion, NTT } from "../ts/index.js"; -import { derivePda } from "../ts/lib/utils.js"; +import { IdlVersion, NTT, getTransceiverProgram } from "../ts/index.js"; import { SolanaNtt } from "../ts/sdk/index.js"; - -const solanaRootDir = `${__dirname}/../`; - +import { + TestDummyTransferHook, + TestHelper, + TestMint, + assert, + signSendWait, +} from "./utils/helpers.js"; + +/** + * Test Config Constants + */ +const SOLANA_ROOT_DIR = `${__dirname}/../`; const VERSION: IdlVersion = "3.0.0"; const TOKEN_PROGRAM = spl.TOKEN_2022_PROGRAM_ID; const GUARDIAN_KEY = @@ -41,84 +46,52 @@ const NTT_ADDRESS: anchor.web3.PublicKey = const WH_TRANSCEIVER_ADDRESS: anchor.web3.PublicKey = anchor.workspace.NttTransceiver.programId; -async function signSendWait( - chain: ChainContext, - txs: AsyncGenerator, - signer: Signer -) { - try { - await ssw(chain, txs, signer); - } catch (e) { - console.error(e); - } -} - +/** + * Test Helpers + */ +const $ = new TestHelper("confirmed", TOKEN_PROGRAM); +const testDummyTransferHook = new TestDummyTransferHook( + anchor.workspace.DummyTransferHook, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID +); +let testMint: TestMint; + +/** + * Wallet Config + */ +const payer = $.keypair.read(`${SOLANA_ROOT_DIR}/keys/test.json`); +const payerAddress = new SolanaAddress(payer.publicKey); + +/** + * Mint Config + */ +const mint = $.keypair.generate(); +const mintAuthority = $.keypair.generate(); + +/** + * Contract Config + */ const w = new Wormhole("Devnet", [SolanaPlatform], { chains: { Solana: { contracts: { coreBridge: CORE_BRIDGE_ADDRESS } } }, }); - -const remoteXcvr: ChainAddress = { - chain: "Ethereum", - address: new UniversalAddress( - encoding.bytes.encode("transceiver".padStart(32, "\0")) - ), -}; -const remoteMgr: ChainAddress = { - chain: "Ethereum", - address: new UniversalAddress( - encoding.bytes.encode("nttManager".padStart(32, "\0")) - ), -}; - -const payerSecretKey = Uint8Array.from( - JSON.parse( - fs.readFileSync(`${solanaRootDir}/keys/test.json`, { - encoding: "utf-8", - }) - ) -); -const payer = anchor.web3.Keypair.fromSecretKey(payerSecretKey); - -const owner = anchor.web3.Keypair.generate(); -const connection = new anchor.web3.Connection( - "http://localhost:8899", - "confirmed" -); - -// make sure we're using the exact same Connection obj for rpc const ctx: ChainContext<"Devnet", "Solana"> = w .getPlatform("Solana") - .getChain("Solana", connection); - -let tokenAccount: anchor.web3.PublicKey; - -const mint = anchor.web3.Keypair.generate(); - -const dummyTransferHook = anchor.workspace - .DummyTransferHook as anchor.Program; - -const [extraAccountMetaListPDA] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("extra-account-metas"), mint.publicKey.toBuffer()], - dummyTransferHook.programId -); - -const [counterPDA] = anchor.web3.PublicKey.findProgramAddressSync( - [Buffer.from("counter")], - dummyTransferHook.programId -); - -async function counterValue(): Promise { - const counter = await dummyTransferHook.account.counter.fetch(counterPDA); - return counter.count; -} - -const coreBridge = new SolanaWormholeCore("Devnet", "Solana", connection, { + .getChain("Solana", $.connection); // make sure we're using the exact same Connection object for rpc +const coreBridge = new SolanaWormholeCore("Devnet", "Solana", $.connection, { coreBridge: CORE_BRIDGE_ADDRESS, }); - +const remoteMgr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "nttManager" +); +const remoteXcvr: ChainAddress = $.chainAddress.generateFromValue( + "Ethereum", + "transceiver" +); const nttTransceivers = { wormhole: getTransceiverProgram( - connection, + $.connection, WH_TRANSCEIVER_ADDRESS.toBase58(), VERSION ), @@ -128,194 +101,116 @@ describe("example-native-token-transfers", () => { let ntt: SolanaNtt<"Devnet", "Solana">; let signer: Signer; let sender: AccountAddress<"Solana">; - let tokenAddress: string; - let multisigTokenAuthority: anchor.web3.PublicKey; + let tokenAccount: anchor.web3.PublicKey; beforeAll(async () => { - try { - signer = await getSolanaSignAndSendSigner(connection, payer, { - //debug: true, - }); - sender = Wormhole.parseAddress("Solana", signer.address()); - - const extensions = [spl.ExtensionType.TransferHook]; - const mintLen = spl.getMintLen(extensions); - const lamports = await connection.getMinimumBalanceForRentExemption( - mintLen - ); - - const transaction = new anchor.web3.Transaction().add( - anchor.web3.SystemProgram.createAccount({ - fromPubkey: payer.publicKey, - newAccountPubkey: mint.publicKey, - space: mintLen, - lamports, - programId: TOKEN_PROGRAM, - }), - spl.createInitializeTransferHookInstruction( - mint.publicKey, - owner.publicKey, - dummyTransferHook.programId, - TOKEN_PROGRAM - ), - spl.createInitializeMintInstruction( - mint.publicKey, - 9, - owner.publicKey, - null, - TOKEN_PROGRAM - ) - ); - - const { blockhash } = await connection.getLatestBlockhash(); - - transaction.feePayer = payer.publicKey; - transaction.recentBlockhash = blockhash; - - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ - payer, - mint, - ]); - - tokenAccount = await spl.createAssociatedTokenAccount( - connection, - payer, - mint.publicKey, - payer.publicKey, - undefined, - TOKEN_PROGRAM, - spl.ASSOCIATED_TOKEN_PROGRAM_ID - ); - - await spl.mintTo( - connection, - payer, - mint.publicKey, - tokenAccount, - owner, - 10_000_000n, - undefined, - undefined, - TOKEN_PROGRAM - ); - - tokenAddress = mint.publicKey.toBase58(); - // create our contract client - ntt = new SolanaNtt( - "Devnet", - "Solana", - connection, - { - ...ctx.config.contracts, - ntt: { - token: tokenAddress, - manager: NTT_ADDRESS.toBase58(), - transceiver: { - wormhole: nttTransceivers["wormhole"].programId.toBase58(), - }, + signer = await getSolanaSignAndSendSigner($.connection, payer, { + //debug: true, + }); + sender = Wormhole.parseAddress("Solana", signer.address()); + + testMint = await TestMint.createWithTokenExtensions( + $.connection, + payer, + mint, + mintAuthority, + 9, + TOKEN_PROGRAM, + spl.ASSOCIATED_TOKEN_PROGRAM_ID, + { + extensions: [spl.ExtensionType.TransferHook], + preMintInitIxs: [ + spl.createInitializeTransferHookInstruction( + mint.publicKey, + mintAuthority.publicKey, + testDummyTransferHook.program.programId, + TOKEN_PROGRAM + ), + ], + } + ); + + tokenAccount = await testMint.mint( + payer, + payer.publicKey, + 10_000_000n, + mintAuthority + ); + + // create our contract client + ntt = new SolanaNtt( + "Devnet", + "Solana", + $.connection, + { + ...ctx.config.contracts, + ntt: { + token: testMint.address.toBase58(), + manager: NTT_ADDRESS.toBase58(), + transceiver: { + wormhole: nttTransceivers["wormhole"].programId.toBase58(), }, }, - VERSION - ); - } catch (e) { - console.error("Failed to setup solana token: ", e); - throw e; - } + }, + VERSION + ); }); describe("Burning", () => { + let multisigTokenAuthority: anchor.web3.PublicKey; + beforeAll(async () => { - try { - multisigTokenAuthority = await spl.createMultisig( - connection, - payer, - [owner.publicKey, ntt.pdas.tokenAuthority()], - 1, - anchor.web3.Keypair.generate(), - undefined, - TOKEN_PROGRAM - ); - await spl.setAuthority( - connection, - payer, - mint.publicKey, - owner, - spl.AuthorityType.MintTokens, - multisigTokenAuthority, - [], - undefined, - TOKEN_PROGRAM - ); + // set multisigTokenAuthority as mint authority + multisigTokenAuthority = await $.multisig.create(payer, 1, [ + mintAuthority.publicKey, + ntt.pdas.tokenAuthority(), + ]); + await testMint.setMintAuthority( + payer, + multisigTokenAuthority, + mintAuthority + ); - // init - const initTxs = ntt.initialize(sender, { - mint: mint.publicKey, - outboundLimit: 1000000n, - mode: "burning", - multisig: multisigTokenAuthority, - }); - await signSendWait(ctx, initTxs, signer); + // init + const initTxs = ntt.initialize(sender, { + mint: testMint.address, + outboundLimit: 1_000_000n, + mode: "burning", + multisig: multisigTokenAuthority, + }); + await signSendWait(ctx, initTxs, signer); - // register - const registerTxs = ntt.registerWormholeTransceiver({ - payer: new SolanaAddress(payer.publicKey), - owner: new SolanaAddress(payer.publicKey), - }); - await signSendWait(ctx, registerTxs, signer); + // register Wormhole xcvr + const registerTxs = ntt.registerWormholeTransceiver({ + payer: payerAddress, + owner: payerAddress, + }); + await signSendWait(ctx, registerTxs, signer); - // set Wormhole xcvr peer - const setXcvrPeerTxs = ntt.setWormholeTransceiverPeer( - remoteXcvr, - sender - ); - await signSendWait(ctx, setXcvrPeerTxs, signer); - - // set manager peer - const setPeerTxs = ntt.setPeer(remoteMgr, 18, 1000000n, sender); - await signSendWait(ctx, setPeerTxs, signer); - } catch (e) { - console.error("Failed to setup peer: ", e); - throw e; - } + // set Wormhole xcvr peer + const setXcvrPeerTxs = ntt.setWormholeTransceiverPeer(remoteXcvr, sender); + await signSendWait(ctx, setXcvrPeerTxs, signer); + + // set manager peer + const setPeerTxs = ntt.setPeer(remoteMgr, 18, 1_000_000n, sender); + await signSendWait(ctx, setPeerTxs, signer); }); it("Create ExtraAccountMetaList Account", async () => { - const initializeExtraAccountMetaListInstruction = - await dummyTransferHook.methods - .initializeExtraAccountMetaList() - .accountsStrict({ - payer: payer.publicKey, - mint: mint.publicKey, - counter: counterPDA, - extraAccountMetaList: extraAccountMetaListPDA, - tokenProgram: TOKEN_PROGRAM, - associatedTokenProgram: spl.ASSOCIATED_TOKEN_PROGRAM_ID, - systemProgram: anchor.web3.SystemProgram.programId, - }) - .instruction(); - - const transaction = new anchor.web3.Transaction().add( - initializeExtraAccountMetaListInstruction - ); - transaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - transaction.recentBlockhash = blockhash; - - transaction.sign(payer); - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ + await testDummyTransferHook.extraAccountMetaList.initialize( + $.connection, payer, - ]); + testMint.address + ); }); it("Can send tokens", async () => { - const amount = 100000n; - const sender = Wormhole.parseAddress("Solana", signer.address()); - + const amount = 100_000n; const receiver = testing.utils.makeUniversalChainAddress("Ethereum"); // TODO: keep or remove the `outboxItem` param? // added as a way to keep tests the same but it technically breaks the Ntt interface - const outboxItem = anchor.web3.Keypair.generate(); + const outboxItem = $.keypair.generate(); const xferTxs = ntt.transfer( sender, amount, @@ -333,11 +228,11 @@ describe("example-native-token-transfers", () => { Object.keys(nttTransceivers).length ); - const wormholeMessage = derivePda( - ["message", outboxItem.publicKey.toBytes()], - nttTransceivers["wormhole"].programId + const wormholeXcvr = await ntt.getWormholeTransceiver(); + expect(wormholeXcvr).toBeTruthy(); + const wormholeMessage = wormholeXcvr!.pdas.wormholeMessageAccount( + outboxItem.publicKey ); - const unsignedVaa = await coreBridge.parsePostMessageAccount( wormholeMessage ); @@ -350,330 +245,223 @@ describe("example-native-token-transfers", () => { // assert that amount is what we expect expect( transceiverMessage.nttManagerPayload.payload.trimmedAmount - ).toMatchObject({ amount: 10000n, decimals: 8 }); + ).toMatchObject({ amount: 10_000n, decimals: 8 }); // get from balance - const balance = await connection.getTokenAccountBalance(tokenAccount); - expect(balance.value.amount).toBe("9900000"); + await assert.tokenBalance($.connection, tokenAccount).equal(9_900_000); }); describe("Can transfer mint authority to-and-from NTT manager", () => { - const newAuthority = anchor.web3.Keypair.generate(); + const newAuthority = $.keypair.generate(); let newMultisigAuthority: anchor.web3.PublicKey; + const nttOwner = payer.publicKey; beforeAll(async () => { - newMultisigAuthority = await spl.createMultisig( - connection, - payer, - [owner.publicKey, newAuthority.publicKey], - 2, - anchor.web3.Keypair.generate(), - undefined, - TOKEN_PROGRAM - ); + newMultisigAuthority = await $.multisig.create(payer, 2, [ + mintAuthority.publicKey, + newAuthority.publicKey, + ]); }); it("Fails when contract is not paused", async () => { - try { - const transaction = new anchor.web3.Transaction().add( - await NTT.createSetTokenAuthorityOneStepUncheckedInstruction( - ntt.program, - await ntt.getConfig(), - { - owner: new SolanaAddress(await ntt.getOwner()).unwrap(), - newAuthority: newAuthority.publicKey, - multisigTokenAuthority, - } + await assert + .promise( + $.sendAndConfirm( + await NTT.createSetTokenAuthorityOneStepUncheckedInstruction( + ntt.program, + await ntt.getConfig(), + { + owner: nttOwner, + newAuthority: newAuthority.publicKey, + multisigTokenAuthority, + } + ), + payer ) - ); - transaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - transaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ - payer, - ]); - // tx should fail so this expect should never be hit - expect(false).toBeTruthy(); - } catch (e) { - expect(e).toBeInstanceOf(anchor.web3.SendTransactionError); - const parsedError = anchor.AnchorError.parse( - (e as anchor.web3.SendTransactionError).logs ?? [] - ); - expect(parsedError?.error.errorCode).toEqual({ + ) + .failsWithAnchorError(anchor.web3.SendTransactionError, { code: "NotPaused", number: 6024, }); - } finally { - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM - ); - expect(mintInfo.mintAuthority).toEqual(multisigTokenAuthority); - } + + await assert.testMintAuthority(testMint).equal(multisigTokenAuthority); }); test("Multisig(owner, TA) -> newAuthority", async () => { // retry after pausing contract - const pauseTxs = await ntt.pause(new SolanaAddress(payer.publicKey)); + const pauseTxs = ntt.pause(payerAddress); await signSendWait(ctx, pauseTxs, signer); - const transaction = new anchor.web3.Transaction().add( + await $.sendAndConfirm( await NTT.createSetTokenAuthorityOneStepUncheckedInstruction( ntt.program, await ntt.getConfig(), { - owner: new SolanaAddress(await ntt.getOwner()).unwrap(), + owner: nttOwner, newAuthority: newAuthority.publicKey, multisigTokenAuthority, } - ) + ), + payer ); - transaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - transaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ - payer, - ]); - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM - ); - expect(mintInfo.mintAuthority).toEqual(newAuthority.publicKey); + await assert.testMintAuthority(testMint).equal(newAuthority.publicKey); }); test("newAuthority -> TA", async () => { - const transaction = new anchor.web3.Transaction().add( + await $.sendAndConfirm( await NTT.createAcceptTokenAuthorityInstruction( ntt.program, await ntt.getConfig(), { currentAuthority: newAuthority.publicKey, } - ) - ); - transaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - transaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ + ), payer, - newAuthority, - ]); - - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM + newAuthority ); - expect(mintInfo.mintAuthority).toEqual(ntt.pdas.tokenAuthority()); + + await assert + .testMintAuthority(testMint) + .equal(ntt.pdas.tokenAuthority()); }); test("TA -> Multisig(owner, newAuthority)", async () => { // set token authority: TA -> newMultisigAuthority - const setTransaction = new anchor.web3.Transaction().add( + await $.sendAndConfirm( await NTT.createSetTokenAuthorityInstruction( ntt.program, await ntt.getConfig(), { - rentPayer: new SolanaAddress(await ntt.getOwner()).unwrap(), - owner: new SolanaAddress(await ntt.getOwner()).unwrap(), + rentPayer: nttOwner, + owner: nttOwner, newAuthority: newMultisigAuthority, } - ) - ); - setTransaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - setTransaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction( - connection, - setTransaction, - [payer] + ), + payer ); // claim token authority: newMultisigAuthority <- TA - const claimTransaction = new anchor.web3.Transaction().add( + await $.sendAndConfirm( await NTT.createClaimTokenAuthorityToMultisigInstruction( ntt.program, await ntt.getConfig(), { - rentPayer: new SolanaAddress(await ntt.getOwner()).unwrap(), + rentPayer: nttOwner, newMultisigAuthority, - additionalSigners: [newAuthority.publicKey, owner.publicKey], + additionalSigners: [ + newAuthority.publicKey, + mintAuthority.publicKey, + ], } - ) - ); - claimTransaction.feePayer = payer.publicKey; - claimTransaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction( - connection, - claimTransaction, - [payer, newAuthority, owner] + ), + payer, + newAuthority, + mintAuthority ); - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM - ); - expect(mintInfo.mintAuthority).toEqual(newMultisigAuthority); + await assert.testMintAuthority(testMint).equal(newMultisigAuthority); }); test("Multisig(owner, newAuthority) -> Multisig(owner, TA)", async () => { - const transaction = new anchor.web3.Transaction().add( + await $.sendAndConfirm( await NTT.createAcceptTokenAuthorityFromMultisigInstruction( ntt.program, await ntt.getConfig(), { currentMultisigAuthority: newMultisigAuthority, - additionalSigners: [newAuthority.publicKey, owner.publicKey], + additionalSigners: [ + newAuthority.publicKey, + mintAuthority.publicKey, + ], multisigTokenAuthority, } - ) - ); - transaction.feePayer = payer.publicKey; - const { blockhash } = await connection.getLatestBlockhash(); - transaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction(connection, transaction, [ + ), payer, newAuthority, - owner, - ]); - - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM + mintAuthority ); - expect(mintInfo.mintAuthority).toEqual(multisigTokenAuthority); + + await assert.testMintAuthority(testMint).equal(multisigTokenAuthority); }); it("Fails on claim after revert", async () => { - try { - // fund newAuthority for it to be rent payer - const signature = await connection.requestAirdrop( - newAuthority.publicKey, - anchor.web3.LAMPORTS_PER_SOL - ); - const { blockhash, lastValidBlockHeight } = - await connection.getLatestBlockhash(); - await connection.confirmTransaction({ - blockhash, - lastValidBlockHeight, - signature, - }); - let newAuthorityBalance = ( - await connection.getAccountInfo(newAuthority.publicKey) - )?.lamports; - expect(newAuthorityBalance).toBe(anchor.web3.LAMPORTS_PER_SOL); - - // set token authority: multisigTokenAuthority -> newAuthority - const setTransaction = new anchor.web3.Transaction().add( - await NTT.createSetTokenAuthorityInstruction( - ntt.program, - await ntt.getConfig(), - { - rentPayer: newAuthority.publicKey, - owner: new SolanaAddress(await ntt.getOwner()).unwrap(), - newAuthority: newAuthority.publicKey, - multisigTokenAuthority, - } - ) - ); - setTransaction.feePayer = payer.publicKey; - setTransaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction( - connection, - setTransaction, - [payer, newAuthority] + // fund newAuthority for it to be rent payer + await $.airdrop(newAuthority.publicKey, anchor.web3.LAMPORTS_PER_SOL); + await assert + .nativeBalance($.connection, newAuthority.publicKey) + .equal(anchor.web3.LAMPORTS_PER_SOL); + + // set token authority: multisigTokenAuthority -> newAuthority + await $.sendAndConfirm( + await NTT.createSetTokenAuthorityInstruction( + ntt.program, + await ntt.getConfig(), + { + rentPayer: newAuthority.publicKey, + owner: nttOwner, + newAuthority: newAuthority.publicKey, + multisigTokenAuthority, + } + ), + payer, + newAuthority + ); + const pendingTokenAuthorityRentExemptAmount = + await $.connection.getMinimumBalanceForRentExemption( + ntt.program.account.pendingTokenAuthority.size ); - newAuthorityBalance = ( - await connection.getAccountInfo(newAuthority.publicKey) - )?.lamports; - const pendingTokenAuthorityRentExemptAmount = - await connection.getMinimumBalanceForRentExemption( - ntt.program.account.pendingTokenAuthority.size - ); - expect(newAuthorityBalance).toBe( + await assert + .nativeBalance($.connection, newAuthority.publicKey) + .equal( anchor.web3.LAMPORTS_PER_SOL - pendingTokenAuthorityRentExemptAmount ); - // revert token authority: multisigTokenAuthority - const revertTransaction = new anchor.web3.Transaction().add( - await NTT.createRevertTokenAuthorityInstruction( - ntt.program, - await ntt.getConfig(), - { - rentPayer: newAuthority.publicKey, - owner: new SolanaAddress(await ntt.getOwner()).unwrap(), - multisigTokenAuthority, - } - ) - ); - revertTransaction.feePayer = payer.publicKey; - revertTransaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction( - connection, - revertTransaction, - [payer] - ); - newAuthorityBalance = ( - await connection.getAccountInfo(newAuthority.publicKey) - )?.lamports; - expect(newAuthorityBalance).toBe(anchor.web3.LAMPORTS_PER_SOL); - - // claim token authority: newAuthority <- multisigTokenAuthority - const claimTransaction = new anchor.web3.Transaction().add( - await NTT.createClaimTokenAuthorityInstruction( - ntt.program, - await ntt.getConfig(), - { - rentPayer: newAuthority.publicKey, - newAuthority: newAuthority.publicKey, - multisigTokenAuthority, - } + // revert token authority: multisigTokenAuthority + await $.sendAndConfirm( + await NTT.createRevertTokenAuthorityInstruction( + ntt.program, + await ntt.getConfig(), + { + rentPayer: newAuthority.publicKey, + owner: nttOwner, + multisigTokenAuthority, + } + ), + payer + ); + await assert + .nativeBalance($.connection, newAuthority.publicKey) + .equal(anchor.web3.LAMPORTS_PER_SOL); + + // claim token authority: newAuthority <- multisigTokenAuthority + await assert + .promise( + $.sendAndConfirm( + await NTT.createClaimTokenAuthorityInstruction( + ntt.program, + await ntt.getConfig(), + { + rentPayer: newAuthority.publicKey, + newAuthority: newAuthority.publicKey, + multisigTokenAuthority, + } + ), + payer, + newAuthority ) - ); - claimTransaction.feePayer = payer.publicKey; - claimTransaction.recentBlockhash = blockhash; - await anchor.web3.sendAndConfirmTransaction( - connection, - claimTransaction, - [payer, newAuthority] - ); - // tx should fail so this expect should never be hit - expect(false).toBeTruthy(); - } catch (e) { - expect(e).toBeInstanceOf(anchor.web3.SendTransactionError); - const parsedError = anchor.AnchorError.parse( - (e as anchor.web3.SendTransactionError).logs ?? [] - ); - expect(parsedError?.error.errorCode).toEqual({ + ) + .failsWithAnchorError(anchor.web3.SendTransactionError, { code: "AccountNotInitialized", number: 3012, }); - } finally { - const mintInfo = await spl.getMint( - connection, - mint.publicKey, - undefined, - TOKEN_PROGRAM - ); - expect(mintInfo.mintAuthority).toEqual(multisigTokenAuthority); - } + + await assert.testMintAuthority(testMint).equal(multisigTokenAuthority); }); afterAll(async () => { // unpause - const unpauseTxs = await ntt.unpause( - new SolanaAddress(payer.publicKey) - ); + const unpauseTxs = ntt.unpause(payerAddress); await signSendWait(ctx, unpauseTxs, signer); }); }); @@ -686,7 +474,6 @@ describe("example-native-token-transfers", () => { ); const guardians = new testing.mocks.MockGuardians(0, [GUARDIAN_KEY]); - const sender = Wormhole.parseAddress("Solana", signer.address()); const sendingTransceiverMessage = { sourceNttManager: remoteMgr.address as UniversalAddress, @@ -698,7 +485,7 @@ describe("example-native-token-transfers", () => { sender: new UniversalAddress("FACE".padStart(64, "0")), payload: { trimmedAmount: { - amount: 10000n, + amount: 10_000n, decimals: 8, }, sourceToken: new UniversalAddress("FAFA".padStart(64, "0")), @@ -718,40 +505,20 @@ describe("example-native-token-transfers", () => { const rawVaa = guardians.addSignatures(published, [0]); const vaa = deserialize("Ntt:WormholeTransfer", serialize(rawVaa)); const redeemTxs = ntt.redeem([vaa], sender, multisigTokenAuthority); - try { - await signSendWait(ctx, redeemTxs, signer); - } catch (e) { - console.error(e); - throw e; - } + await signSendWait(ctx, redeemTxs, signer); - expect((await counterValue()).toString()).toEqual("2"); + assert.bn(await testDummyTransferHook.counter.value()).equal(2); }); it("Can mint independently", async () => { - const dest = await spl.getOrCreateAssociatedTokenAccount( - connection, + const temp = await testMint.mint( payer, - mint.publicKey, - anchor.web3.Keypair.generate().publicKey, - false, - undefined, - undefined, - TOKEN_PROGRAM - ); - await spl.mintTo( - connection, - payer, - mint.publicKey, - dest.address, - multisigTokenAuthority, + $.keypair.generate().publicKey, 1, - [owner], - undefined, - TOKEN_PROGRAM + multisigTokenAuthority, + mintAuthority ); - const balance = await connection.getTokenAccountBalance(dest.address); - expect(balance.value.amount.toString()).toBe("1"); + await assert.tokenBalance($.connection, temp).equal(1); }); }); @@ -760,7 +527,7 @@ describe("example-native-token-transfers", () => { const ctx = wh.getChain("Solana"); const overrides = { Solana: { - token: tokenAddress, + token: mint.publicKey.toBase58(), manager: NTT_ADDRESS.toBase58(), transceiver: { wormhole: nttTransceivers["wormhole"].programId.toBase58(), @@ -770,7 +537,7 @@ describe("example-native-token-transfers", () => { describe("ABI Versions Test", () => { test("It initializes from Rpc", async () => { - const ntt = await SolanaNtt.fromRpc(connection, { + const ntt = await SolanaNtt.fromRpc($.connection, { Solana: { ...ctx.config, contracts: { @@ -783,7 +550,7 @@ describe("example-native-token-transfers", () => { }); test("It initializes from constructor", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", connection, { + const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { ...ctx.config.contracts, ...{ ntt: overrides["Solana"] }, }); @@ -792,9 +559,9 @@ describe("example-native-token-transfers", () => { test("It gets the correct version", async () => { const version = await SolanaNtt.getVersion( - connection, + $.connection, { ntt: overrides["Solana"] }, - new SolanaAddress(payer.publicKey.toBase58()) + payerAddress ); expect(version).toBe("3.0.0"); }); @@ -807,7 +574,7 @@ describe("example-native-token-transfers", () => { .emitterAccount() .toBase58(); - const ntt = new SolanaNtt("Devnet", "Solana", connection, { + const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { ...ctx.config.contracts, ...{ ntt: overrideEmitter }, }); @@ -815,13 +582,14 @@ describe("example-native-token-transfers", () => { }); test("It gets the correct transceiver type", async () => { - const ntt = new SolanaNtt("Devnet", "Solana", connection, { + const ntt = new SolanaNtt("Devnet", "Solana", $.connection, { ...ctx.config.contracts, ...{ ntt: overrides["Solana"] }, }); const whTransceiver = await ntt.getWormholeTransceiver(); + expect(whTransceiver).toBeTruthy(); const transceiverType = await whTransceiver!.getTransceiverType( - new SolanaAddress(payer.publicKey.toBase58()) + payerAddress ); expect(transceiverType).toBe("wormhole"); }); diff --git a/solana/tests/utils/helpers.ts b/solana/tests/utils/helpers.ts new file mode 100644 index 000000000..784fe7b70 --- /dev/null +++ b/solana/tests/utils/helpers.ts @@ -0,0 +1,645 @@ +import * as anchor from "@coral-xyz/anchor"; +import * as spl from "@solana/spl-token"; +import * as fs from "fs"; +import { + Chain, + ChainAddress, + ChainContext, + encoding, + Signer, + signSendWait as ssw, + UniversalAddress, +} from "@wormhole-foundation/sdk"; +import { DummyTransferHook } from "../../ts/idl/1_0_0/ts/dummy_transfer_hook.js"; +import { derivePda } from "../../ts/lib/utils.js"; + +export interface ErrorConstructor { + new (...args: any[]): Error; +} + +/** + * Assertion utility functions + */ +export const assert = { + /** + * Asserts BN + * @param actual BN to compare against + */ + bn: (actual: anchor.BN) => ({ + /** + * Asserts `actual` equals `expected` + * @param expected BN to compare with + */ + equal: (expected: anchor.BN | number | string | bigint) => { + expect( + actual.eq( + expected instanceof anchor.BN + ? expected + : new anchor.BN(expected.toString()) + ) + ).toBeTruthy(); + }, + }), + + /** + * Asserts mint authority for given `mint` + * @param connection Connection to use + * @param mint Mint account + * @param tokenProgram SPL Token program account + */ + mintAuthority: ( + connection: anchor.web3.Connection, + mint: anchor.web3.PublicKey, + tokenProgram = spl.TOKEN_2022_PROGRAM_ID + ) => ({ + /** + * Asserts queried mint authority equals `expectedAuthority` + * @param expectedAuthority Expected mint authority + */ + equal: async (expectedAuthority: anchor.web3.PublicKey) => { + const mintInfo = await spl.getMint( + connection, + mint, + undefined, + tokenProgram + ); + expect(mintInfo.mintAuthority).toEqual(expectedAuthority); + }, + }), + + /** + * Asserts mint authority for given `testMint` + * @param testMint `TestMint` object to query to fetch mintAuthority + */ + testMintAuthority: (testMint: TestMint) => ({ + /** + * Asserts queried mint authority equals `expectedAuthority` + * @param expectedAuthority Expected mint authority + */ + equal: async (expectedAuthority: anchor.web3.PublicKey) => { + const mintInfo = await testMint.getMint(); + expect(mintInfo.mintAuthority).toEqual(expectedAuthority); + }, + }), + + /** + * Asserts native balance for given `publicKey` + * @param connection Connection to use + * @param publicKey Account to query to fetch native balance + * @returns + */ + nativeBalance: ( + connection: anchor.web3.Connection, + publicKey: anchor.web3.PublicKey + ) => ({ + /** + * Asserts queried native balance equals `expectedBalance` + * @param expectedBalance Expected lamports balance + */ + equal: async (expectedBalance: anchor.BN | number | string | bigint) => { + const balance = await connection.getAccountInfo(publicKey); + expect(balance?.lamports.toString()).toBe(expectedBalance.toString()); + }, + }), + + /** + * Asserts token balance for given `tokenAccount` + * @param connection Connection to use + * @param tokenAccount Token account to query to fetch token balance + */ + tokenBalance: ( + connection: anchor.web3.Connection, + tokenAccount: anchor.web3.PublicKey + ) => ({ + /** + * Asserts queried token balance equals `expectedBalance` + * @param expectedBalance Expected token balance + */ + equal: async (expectedBalance: anchor.BN | number | string | bigint) => { + const balance = await connection.getTokenAccountBalance(tokenAccount); + expect(balance.value.amount).toBe(expectedBalance.toString()); + }, + }), + + /** + * Asserts promise fails and throws expected error + * @param prom Promise to execute (intended to fail) + */ + promise: (prom: Promise) => ({ + /** + * Asserts promise throws error of type `errorType` + * @param errorType Expected type for thrown error + */ + fails: async (errorType?: ErrorConstructor) => { + let result: any; + try { + result = await prom; + } catch (error: any) { + if (errorType != null) { + expect(error).toBeInstanceOf(errorType); + } + return; + } + throw new Error(`Promise did not fail. Result: ${result}`); + }, + /** + * Asserts promise throws error containing `message` + * @param message Expected message contained in thrown error + */ + failsWith: async (message: string) => { + let result: any; + try { + result = await prom; + } catch (error: any) { + const errorStr: string = error.toString(); + if (errorStr.includes(message)) { + return; + } + throw { + message: "Error does not contain the asked message", + stack: errorStr, + }; + } + throw new Error(`Promise did not fail. Result: ${result}`); + }, + /** + * Asserts promise throws Anchor error coreesponding to type `errorType` and `errorCode` + * @param errorType Expected type for thrown error + * @param errorCode Expected error code for thrown error + */ + failsWithAnchorError: async ( + errorType: ErrorConstructor, + errorCode: typeof anchor.AnchorError.prototype.error.errorCode + ) => { + let result: any; + try { + result = await prom; + } catch (error: any) { + expect(error).toBeInstanceOf(errorType); + const parsedError = anchor.AnchorError.parse(error.logs ?? []); + expect(parsedError?.error.errorCode).toEqual(errorCode); + return; + } + throw new Error(`Promise did not fail. Result: ${result}`); + }, + }), +}; + +/** + * General test utility class + */ +export class TestHelper { + static readonly LOCALHOST = "http://localhost:8899"; + readonly connection: anchor.web3.Connection; + + constructor( + readonly finality: anchor.web3.Finality = "confirmed", + readonly tokenProgram: anchor.web3.PublicKey = spl.TOKEN_2022_PROGRAM_ID + ) { + this.connection = new anchor.web3.Connection( + TestHelper.LOCALHOST, + finality + ); + } + + /** + * `Keypair` utility functions + */ + keypair = { + /** + * Wrapper around `Keypair.generate()` + * @returns Generated `Keypair` + */ + generate: () => anchor.web3.Keypair.generate(), + /** + * Reads secret key file and returns `Keypair` it corresponds to + * @param path File path containing secret key + * @returns Corresponding `Keypair` + */ + read: (path: string) => + this.keypair.from( + JSON.parse(fs.readFileSync(path, { encoding: "utf8" })) + ), + /** + * Wrapper around `Keypair.fromSecretKey` for number array-like + * @param bytes Number array-like corresponding to a secret key + * @returns Corresponding `Keypair` + */ + from: (bytes: number[]) => + anchor.web3.Keypair.fromSecretKey(Uint8Array.from(bytes)), + }; + + /** + * `ChainAddress` utility functions + */ + chainAddress = { + /** + * Generates a `ChainAddress` by encoding value to pass off as `UniversalAddress` + * @param chain `Chain` to generate `ChainAddress` for + * @param value String to use for generating `UniversalAddress` + * @returns Generated `ChainAddress` + */ + generateFromValue: (chain: Chain, value: string): ChainAddress => ({ + chain, + address: new UniversalAddress( + encoding.bytes.encode(value.padStart(32, "\0")) + ), + }), + }; + + /** + * SPL Multisig utility functions + */ + multisig = { + /** + * Wrapper around `spl.createMultisig` + * @param payer Payer of the transaction and initialization fees + * @param m Number of required signatures + * @param signers Full set of signers + * @returns Address of the new multisig + */ + create: async ( + payer: anchor.web3.Signer, + m: number, + signers: anchor.web3.PublicKey[] + ) => { + return spl.createMultisig( + this.connection, + payer, + signers, + m, + this.keypair.generate(), + undefined, + this.tokenProgram + ); + }, + }; + + /** + * Wrapper around `confirmTransaction` + * @param signature Signature of transaction to confirm + * @returns Result of signature confirmation + */ + confirm = async (signature: anchor.web3.TransactionSignature) => { + const { blockhash, lastValidBlockHeight } = + await this.connection.getLatestBlockhash(); + return this.connection.confirmTransaction({ + blockhash, + lastValidBlockHeight, + signature, + }); + }; + + /** + * Wrapper around `sendAndConfirm` for `this.connection` + * @param ixs Instruction(s)/transaction used to create the transaction + * @param payer Payer of the transaction fees + * @param signers Signing accounts required by the transaction + * @returns Signature of the confirmed transaction + */ + sendAndConfirm = async ( + ixs: + | anchor.web3.TransactionInstruction + | anchor.web3.Transaction + | Array, + payer: anchor.web3.Signer, + ...signers: anchor.web3.Signer[] + ): Promise => { + return sendAndConfirm(this.connection, ixs, payer, ...signers); + }; + + /** + * Wrapper around `requestAirdrop()` + * @param to Recipient account for airdrop + * @param lamports Amount in lamports to airdrop + * @returns + */ + airdrop = async (to: anchor.web3.PublicKey, lamports: number) => { + return this.confirm(await this.connection.requestAirdrop(to, lamports)); + }; +} + +/** + * Mint-related test utility class + */ +export class TestMint { + private constructor( + readonly connection: anchor.web3.Connection, + readonly address: anchor.web3.PublicKey, + readonly decimals: number, + readonly tokenProgram: anchor.web3.PublicKey = spl.TOKEN_2022_PROGRAM_ID, + readonly associatedTokenProgram: anchor.web3.PublicKey = spl.ASSOCIATED_TOKEN_PROGRAM_ID + ) {} + + /** + * Creates and initializes a new mint + * @param connection Connection to use + * @param payer Payer of the transaction and initialization fees + * @param authority Account that will control minting + * @param decimals Location of the decimal place + * @param tokenProgram SPL Token program account + * @param associatedTokenProgram SPL Associated Token program account + * @returns new `TestMint` object initialized with the created mint + */ + static create = async ( + connection: anchor.web3.Connection, + payer: anchor.web3.Signer, + authority: anchor.web3.Signer, + decimals: number, + tokenProgram: anchor.web3.PublicKey = spl.TOKEN_2022_PROGRAM_ID, + associatedTokenProgram: anchor.web3.PublicKey = spl.ASSOCIATED_TOKEN_PROGRAM_ID + ) => { + return new TestMint( + connection, + await spl.createMint( + connection, + payer, + authority.publicKey, + null, + decimals, + undefined, + undefined, + tokenProgram + ), + decimals, + tokenProgram, + associatedTokenProgram + ); + }; + + /** + * Creates and initializes a new mint with Token Extensions + * @param connection Connection to use + * @param payer Payer of the transaction and initialization fees + * @param mint Keypair of mint to be created + * @param authority Account that will control minting + * @param decimals Location of the decimal place + * @param tokenProgram SPL Token program account + * @param associatedTokenProgram SPL Associated Token program account + * @param extensionArgs.extensions Token extensions mint is to be initialized with + * @param extensionArgs.additionalDataLength Additional space to allocate for extension + * @param extensionArgs.preMintInitIxs Instructions to execute before `InitializeMint` instruction + * @param extensionArgs.postMintInitIxs Instructions to execute after `InitializeMint` instruction + * @returns new `TestMint` object initialized with the created mint + */ + static createWithTokenExtensions = async ( + connection: anchor.web3.Connection, + payer: anchor.web3.Signer, + mint: anchor.web3.Keypair, + authority: anchor.web3.Signer, + decimals: number, + tokenProgram: anchor.web3.PublicKey = spl.TOKEN_2022_PROGRAM_ID, + associatedTokenProgram: anchor.web3.PublicKey = spl.ASSOCIATED_TOKEN_PROGRAM_ID, + extensionArgs: { + extensions: spl.ExtensionType[]; + additionalDataLength?: number; + preMintInitIxs?: anchor.web3.TransactionInstruction[]; + postMintInitIxs?: anchor.web3.TransactionInstruction[]; + } + ) => { + const mintLen = spl.getMintLen(extensionArgs.extensions); + const additionalDataLength = extensionArgs.additionalDataLength ?? 0; + const lamports = await connection.getMinimumBalanceForRentExemption( + mintLen + additionalDataLength + ); + await sendAndConfirm( + connection, + [ + anchor.web3.SystemProgram.createAccount({ + fromPubkey: payer.publicKey, + newAccountPubkey: mint.publicKey, + space: mintLen, + lamports, + programId: tokenProgram, + }), + ...(extensionArgs.preMintInitIxs ?? []), + spl.createInitializeMintInstruction( + mint.publicKey, + decimals, + authority.publicKey, + null, + tokenProgram + ), + ...(extensionArgs.postMintInitIxs ?? []), + ], + payer, + mint + ); + + return new TestMint( + connection, + mint.publicKey, + decimals, + tokenProgram, + associatedTokenProgram + ); + }; + + /** + * Wrapper around `spl.getMint` + * @returns Mint information + */ + getMint = async () => { + return spl.getMint( + this.connection, + this.address, + undefined, + this.tokenProgram + ); + }; + + /** + * Creates ATA for `accountOwner` and mints `amount` tokens to it + * @param payer Payer of the transaction and initialization fees + * @param accountOwner Owner of token account + * @param amount Amount to mint + * @param mintAuthority Minting authority + * @param multiSigners Signing accounts if `mintAuthority` is a multisig + * @returns Address of ATA + */ + mint = async ( + payer: anchor.web3.Signer, + accountOwner: anchor.web3.PublicKey, + amount: number | bigint, + mintAuthority: anchor.web3.Signer | anchor.web3.PublicKey, + ...multiSigners: anchor.web3.Signer[] + ) => { + const tokenAccount = await spl.getOrCreateAssociatedTokenAccount( + this.connection, + payer, + this.address, + accountOwner, + false, + undefined, + undefined, + this.tokenProgram, + this.associatedTokenProgram + ); + + await spl.mintTo( + this.connection, + payer, + this.address, + tokenAccount.address, + mintAuthority, + amount, + multiSigners, + undefined, + this.tokenProgram + ); + + return tokenAccount.address; + }; + + /** + * Wrapper around `spl.setAuthority` for `spl.AuthorityType.MintTokens` + * @param payer Payer of the transaction fees + * @param newAuthority New mint authority + * @param currentAuthority Current mint authority + * @param multiSigners Signing accounts if `currentAuthority` is a multisig + * @returns Signature of the confirmed transaction + */ + setMintAuthority = async ( + payer: anchor.web3.Signer, + newAuthority: anchor.web3.PublicKey, + currentAuthority: anchor.web3.Signer | anchor.web3.PublicKey, + ...multiSigners: anchor.web3.Signer[] + ) => { + return spl.setAuthority( + this.connection, + payer, + this.address, + currentAuthority, + spl.AuthorityType.MintTokens, + newAuthority, + multiSigners, + undefined, + this.tokenProgram + ); + }; +} + +/** + * Dummy Transfer Hook program related test utility class + */ +export class TestDummyTransferHook { + constructor( + readonly program: anchor.Program, + readonly tokenProgram = spl.TOKEN_2022_PROGRAM_ID, + readonly associatedTokenProgram = spl.ASSOCIATED_TOKEN_PROGRAM_ID + ) {} + + /** + * Counter utility functions + */ + counter = { + /** + * @returns Counter PDA + */ + pda: () => derivePda(["counter"], this.program.programId), + + /** + * Queries counter and returns counter count + * @returns Queried counter value + */ + value: async () => { + const counter = await this.program.account.counter.fetch( + this.counter.pda() + ); + return counter.count; + }, + }; + + /** + * Extra Account Meta List utility functions + */ + extraAccountMetaList = { + /** + * @param mint Mint account + * @returns Extra Account Meta List PDA + */ + pda: (mint: anchor.web3.PublicKey) => + derivePda( + ["extra-account-metas", mint.toBytes()], + this.program.programId + ), + /** + * Initializes Extra Account Meta List account + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param mint Mint account + * @returns Signature of the confirmed transaction + */ + initialize: async ( + connection: anchor.web3.Connection, + payer: anchor.web3.Signer, + mint: anchor.web3.PublicKey + ) => { + return sendAndConfirm( + connection, + await this.program.methods + .initializeExtraAccountMetaList() + .accountsStrict({ + payer: payer.publicKey, + mint, + counter: this.counter.pda(), + extraAccountMetaList: this.extraAccountMetaList.pda(mint), + tokenProgram: this.tokenProgram, + associatedTokenProgram: this.associatedTokenProgram, + systemProgram: anchor.web3.SystemProgram.programId, + }) + .instruction(), + payer + ); + }, + }; +} + +/** + * Try-catch wrapper around `signSendWait` + * @param chain Chain to execute transaction on + * @param txs Generator of unsigned transactions + * @param signer Signing account required by the transactions + */ +export const signSendWait = async ( + chain: ChainContext, + txs: AsyncGenerator, + signer: Signer +) => { + try { + await ssw(chain, txs, signer); + } catch (e) { + console.error(e); + } +}; + +/** + * Wrapper around `sendAndConfirmTransaction` + * @param connection Connection to use + * @param ixs Instruction(s)/transaction used to create the transaction + * @param payer Payer of the transaction fees + * @param signers Signing accounts required by the transaction + * @returns Signature of the confirmed transaction + */ +export const sendAndConfirm = async ( + connection: anchor.web3.Connection, + ixs: + | anchor.web3.TransactionInstruction + | anchor.web3.Transaction + | Array, + payer: anchor.web3.Signer, + ...signers: anchor.web3.Signer[] +): Promise => { + const { value } = await connection.getLatestBlockhashAndContext(); + const tx = new anchor.web3.Transaction({ + ...value, + feePayer: payer.publicKey, + }).add(...(Array.isArray(ixs) ? ixs : [ixs])); + + return anchor.web3.sendAndConfirmTransaction( + connection, + tx, + [payer, ...signers], + {} + ); +};