diff --git a/solana/Anchor.toml b/solana/Anchor.toml index 4547cc2..908e6ec 100644 --- a/solana/Anchor.toml +++ b/solana/Anchor.toml @@ -30,6 +30,10 @@ startup_wait = 16000 [test.validator] url = "https://api.devnet.solana.com" +### At 160 ticks/s, 64 ticks per slot implies that leader rotation and voting will happen +### every 400 ms. A fast voting cadence ensures faster finality and convergence +ticks_per_slot = 8 + ### Forked Wormhole Circle Integration Program [[test.validator.clone]] address = "wCCTPvsyeL9qYqbHTv3DUAyzEfYcyHoYw5c4mgcbBeW" diff --git a/solana/Cargo.lock b/solana/Cargo.lock index 34343dc..5708caf 100644 --- a/solana/Cargo.lock +++ b/solana/Cargo.lock @@ -2395,6 +2395,7 @@ dependencies = [ name = "wormhole-circle-integration-solana" version = "0.0.1-alpha.7" dependencies = [ + "ahash 0.8.6", "anchor-lang", "anchor-spl", "cfg-if", diff --git a/solana/Cargo.toml b/solana/Cargo.toml index 68507e7..912b8e1 100644 --- a/solana/Cargo.toml +++ b/solana/Cargo.toml @@ -39,6 +39,10 @@ ruint = "1.9.0" cfg-if = "1.0" hex-literal = "0.4.1" +### https://github.com/coral-xyz/anchor/issues/2755 +### This dependency must be added for each program. +ahash = "=0.8.6" + [profile.release] overflow-checks = true lto = "fat" diff --git a/solana/programs/circle-integration/Cargo.toml b/solana/programs/circle-integration/Cargo.toml index 41bac6f..19104fd 100644 --- a/solana/programs/circle-integration/Cargo.toml +++ b/solana/programs/circle-integration/Cargo.toml @@ -34,5 +34,7 @@ ruint.workspace = true cfg-if.workspace = true +ahash.workspace = true + [dev-dependencies] hex-literal.workspace = true \ No newline at end of file diff --git a/solana/programs/circle-integration/src/processor/transfer_tokens_with_payload.rs b/solana/programs/circle-integration/src/processor/transfer_tokens_with_payload.rs index 8fa579f..6458d4d 100644 --- a/solana/programs/circle-integration/src/processor/transfer_tokens_with_payload.rs +++ b/solana/programs/circle-integration/src/processor/transfer_tokens_with_payload.rs @@ -22,10 +22,6 @@ pub struct TransferTokensWithPayload<'info> { )] custodian: Account<'info, Custodian>, - /// Signer who must have the authority (either as the owner or has been delegated authority) - /// over the `burn_source` token account. - burn_source_authority: Signer<'info>, - /// Circle-supported mint. /// /// CHECK: Mutable. This token account's mint must be the same as the one found in the CCTP @@ -38,6 +34,9 @@ pub struct TransferTokensWithPayload<'info> { /// Token account where assets are burned from. The CCTP Token Messenger Minter program will /// burn the configured [amount](TransferTokensWithPayloadArgs::amount) from this account. + /// + /// NOTE: Transfer authority must be delegated to the custodian because this instruction + /// transfers assets from this account to the custody token account. #[account( mut, token::mint = mint @@ -160,22 +159,23 @@ pub fn transfer_tokens_with_payload( payload, } = args; + let custodian_seeds = &[Custodian::SEED_PREFIX, &[ctx.accounts.custodian.bump]]; + // Because the transfer initiator in the Circle message is whoever signs to burn assets, we need // to transfer assets from the source token account to one that belongs to this program. token::transfer( - CpiContext::new( + CpiContext::new_with_signer( ctx.accounts.token_program.to_account_info(), token::Transfer { from: ctx.accounts.burn_source.to_account_info(), to: ctx.accounts.custody_token.to_account_info(), - authority: ctx.accounts.burn_source_authority.to_account_info(), + authority: ctx.accounts.custodian.to_account_info(), }, + &[custodian_seeds], ), amount, )?; - let custodian_seeds = &[Custodian::SEED_PREFIX, &[ctx.accounts.custodian.bump]]; - wormhole_cctp_solana::cpi::burn_and_publish( CpiContext::new_with_signer( ctx.accounts diff --git a/solana/ts/scripts/setupTestnet.ts b/solana/ts/scripts/setupTestnet.ts index 96771b7..fead868 100644 --- a/solana/ts/scripts/setupTestnet.ts +++ b/solana/ts/scripts/setupTestnet.ts @@ -9,7 +9,7 @@ import { NodeWallet, postVaaSolana } from "@certusone/wormhole-sdk/lib/cjs/solan import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import "dotenv/config"; -import { WormholeCctpProgram } from "../src"; +import { CircleIntegrationProgram } from "../src"; const PROGRAM_ID = "wCCTPvsyeL9qYqbHTv3DUAyzEfYcyHoYw5c4mgcbBeW"; @@ -22,7 +22,7 @@ async function main() { let govSequence = 6900n; const connection = new Connection("https://api.devnet.solana.com", "confirmed"); - const wormholeCctp = new WormholeCctpProgram(connection, PROGRAM_ID); + const circleIntegration = new CircleIntegrationProgram(connection, PROGRAM_ID); if (process.env.SOLANA_PRIVATE_KEY === undefined) { throw new Error("SOLANA_PRIVATE_KEY is undefined"); @@ -39,7 +39,7 @@ async function main() { const cctpDomain = 0; await registerEmitterAndDomain( - wormholeCctp, + circleIntegration, payer, govSequence++, foreignChain, @@ -53,7 +53,7 @@ async function main() { const cctpDomain = 1; await registerEmitterAndDomain( - wormholeCctp, + circleIntegration, payer, govSequence++, foreignChain, @@ -67,7 +67,7 @@ async function main() { const cctpDomain = 2; await registerEmitterAndDomain( - wormholeCctp, + circleIntegration, payer, govSequence++, foreignChain, @@ -81,7 +81,7 @@ async function main() { const cctpDomain = 3; await registerEmitterAndDomain( - wormholeCctp, + circleIntegration, payer, govSequence++, foreignChain, @@ -91,32 +91,34 @@ async function main() { } } -async function intialize(wormholeCctp: WormholeCctpProgram, payer: Keypair) { - console.log("custodian", wormholeCctp.custodianAddress().toString()); +async function intialize(circleIntegration: CircleIntegrationProgram, payer: Keypair) { + console.log("custodian", circleIntegration.custodianAddress().toString()); - const ix = await wormholeCctp.initializeIx(payer.publicKey); + const ix = await circleIntegration.initializeIx(payer.publicKey); - const connection = wormholeCctp.program.provider.connection; + const connection = circleIntegration.program.provider.connection; const txSig = await sendAndConfirmTransaction(connection, new Transaction().add(ix), [payer]); console.log("intialize", txSig); } async function registerEmitterAndDomain( - wormholeCctp: WormholeCctpProgram, + circleIntegration: CircleIntegrationProgram, payer: Keypair, govSequence: bigint, foreignChain: ChainName, foreignEmitter: string, cctpDomain: number, ) { - const connection = wormholeCctp.program.provider.connection; + const connection = circleIntegration.program.provider.connection; - const registeredEmitter = wormholeCctp.registeredEmitterAddress(coalesceChainId(foreignChain)); + const registeredEmitter = circleIntegration.registeredEmitterAddress( + coalesceChainId(foreignChain), + ); const emitterAddress = Array.from(tryNativeToUint8Array(foreignEmitter, foreignChain)); const exists = await connection.getAccountInfo(registeredEmitter).then((acct) => acct != null); if (exists) { - const registered = await wormholeCctp.fetchRegisteredEmitter(registeredEmitter); + const registered = await circleIntegration.fetchRegisteredEmitter(registeredEmitter); if (Buffer.from(registered.address).equals(Buffer.from(emitterAddress))) { console.log("already registered", foreignChain, foreignEmitter, cctpDomain); return; @@ -157,14 +159,14 @@ async function registerEmitterAndDomain( await postVaaSolana( connection, new NodeWallet(payer).signTransaction, - wormholeCctp.coreBridgeProgramId(), + circleIntegration.coreBridgeProgramId(), payer.publicKey, vaaBuf, ); - const vaa = derivePostedVaaKey(wormholeCctp.coreBridgeProgramId(), parseVaa(vaaBuf).hash); + const vaa = derivePostedVaaKey(circleIntegration.coreBridgeProgramId(), parseVaa(vaaBuf).hash); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); diff --git a/solana/ts/scripts/upgradeTestnet.ts b/solana/ts/scripts/upgradeTestnet.ts index 82e8afd..e20834d 100644 --- a/solana/ts/scripts/upgradeTestnet.ts +++ b/solana/ts/scripts/upgradeTestnet.ts @@ -4,7 +4,7 @@ import { NodeWallet, postVaaSolana } from "@certusone/wormhole-sdk/lib/cjs/solan import { derivePostedVaaKey } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhole"; import { Connection, Keypair, Transaction, sendAndConfirmTransaction } from "@solana/web3.js"; import "dotenv/config"; -import { WormholeCctpProgram } from "../src"; +import { CircleIntegrationProgram } from "../src"; const PROGRAM_ID = "wCCTPvsyeL9qYqbHTv3DUAyzEfYcyHoYw5c4mgcbBeW"; @@ -20,22 +20,22 @@ async function main() { let govSequence = 6910n; const connection = new Connection("https://api.devnet.solana.com", "confirmed"); - const wormholeCctp = new WormholeCctpProgram(connection, PROGRAM_ID); + const circleIntegration = new CircleIntegrationProgram(connection, PROGRAM_ID); if (process.env.SOLANA_PRIVATE_KEY === undefined) { throw new Error("SOLANA_PRIVATE_KEY is undefined"); } const payer = Keypair.fromSecretKey(Buffer.from(process.env.SOLANA_PRIVATE_KEY, "hex")); - await upgradeContract(wormholeCctp, payer, govSequence); + await upgradeContract(circleIntegration, payer, govSequence); } async function upgradeContract( - wormholeCctp: WormholeCctpProgram, + circleIntegration: CircleIntegrationProgram, payer: Keypair, govSequence: bigint, ) { - const connection = wormholeCctp.program.provider.connection; + const connection = circleIntegration.program.provider.connection; const govEmitter = new MockEmitter( "0000000000000000000000000000000000000000000000000000000000000004", @@ -69,14 +69,14 @@ async function upgradeContract( await postVaaSolana( connection, new NodeWallet(payer).signTransaction, - wormholeCctp.coreBridgeProgramId(), + circleIntegration.coreBridgeProgramId(), payer.publicKey, vaaBuf, ); - const vaa = derivePostedVaaKey(wormholeCctp.coreBridgeProgramId(), parseVaa(vaaBuf).hash); + const vaa = derivePostedVaaKey(circleIntegration.coreBridgeProgramId(), parseVaa(vaaBuf).hash); - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, }); diff --git a/solana/ts/src/index.ts b/solana/ts/src/index.ts index 95f615a..cc7b7cc 100644 --- a/solana/ts/src/index.ts +++ b/solana/ts/src/index.ts @@ -115,7 +115,7 @@ export type SolanaWormholeCctpTxData = { encodedCctpMessage: Buffer; }; -export class WormholeCctpProgram { +export class CircleIntegrationProgram { private _programId: ProgramId; program: Program; @@ -346,23 +346,10 @@ export class WormholeCctpProgram { mint: PublicKey; burnSource: PublicKey; coreMessage: PublicKey; - burnSourceAuthority?: PublicKey; }, args: TransferTokensWithPayloadArgs, ): Promise { - let { - payer, - burnSource, - mint, - coreMessage, - burnSourceAuthority: inputBurnSender, - } = accounts; - - const burnSourceAuthority = - inputBurnSender ?? - (await splToken - .getAccount(this.program.provider.connection, burnSource) - .then((token) => token.owner)); + let { payer, burnSource, mint, coreMessage } = accounts; const { amount, targetChain, mintRecipient, wormholeMessageNonce, payload } = args; @@ -395,7 +382,6 @@ export class WormholeCctpProgram { .accounts({ payer, custodian, - burnSourceAuthority, mint, burnSource, custodyToken, diff --git a/solana/ts/tests/01__circleIntegration.ts b/solana/ts/tests/01__circleIntegration.ts index cbf5a24..085629a 100644 --- a/solana/ts/tests/01__circleIntegration.ts +++ b/solana/ts/tests/01__circleIntegration.ts @@ -4,7 +4,14 @@ import { getPostedMessage } from "@certusone/wormhole-sdk/lib/cjs/solana/wormhol import * as anchor from "@coral-xyz/anchor"; import * as splToken from "@solana/spl-token"; import { expect } from "chai"; -import { CctpTokenBurnMessage, Deposit, DepositHeader, WormholeCctpProgram } from "../src"; +import { + CctpTokenBurnMessage, + Deposit, + DepositHeader, + CircleIntegrationProgram, + VaaAccount, + Claim, +} from "../src"; import { CircleAttester, ETHEREUM_USDC_ADDRESS, @@ -25,7 +32,7 @@ describe("Circle Integration -- Localnet", () => { const connection = new anchor.web3.Connection("http://localhost:8899", "processed"); const payer = anchor.web3.Keypair.fromSecretKey(PAYER_PRIVATE_KEY); - const wormholeCctp = new WormholeCctpProgram( + const circleIntegration = new CircleIntegrationProgram( connection, "Wormho1eCirc1e1ntegration111111111111111111", ); @@ -34,7 +41,7 @@ describe("Circle Integration -- Localnet", () => { describe("Setup", () => { it("Invoke `initialize`", async () => { - const ix = await wormholeCctp.initializeIx(payer.publicKey); + const ix = await circleIntegration.initializeIx(payer.publicKey); await expectIxOk(connection, [ix], [payer]); }); @@ -49,7 +56,7 @@ describe("Circle Integration -- Localnet", () => { ); await expectIxOk(connection, [createIx], [payer]); - const usdcCommonAccounts = wormholeCctp.commonAccounts(USDC_MINT_ADDRESS); + const usdcCommonAccounts = circleIntegration.commonAccounts(USDC_MINT_ADDRESS); // Extend. const extendIx = anchor.web3.AddressLookupTableProgram.extendLookupTable({ @@ -85,12 +92,12 @@ describe("Circle Integration -- Localnet", () => { }, }); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); - await expectIxErr(connection, [ix], [payer], "GovernanceForAnotherChain"); + await expectIxErr(connection, [ix], [payer], "Error Code: GovernanceForAnotherChain"); }); it("Cannot Invoke `register_emitter_and_domain` with Invalid Governance", async () => { @@ -101,7 +108,7 @@ describe("Circle Integration -- Localnet", () => { }, }); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, remoteTokenMessenger: new anchor.web3.PublicKey( @@ -109,7 +116,7 @@ describe("Circle Integration -- Localnet", () => { ), }); - await expectIxErr(connection, [ix], [payer], "InvalidGovernanceAction"); + await expectIxErr(connection, [ix], [payer], "Error Code: InvalidGovernanceAction"); }); it("Cannot Invoke `register_emitter_and_domain` with Invalid CCTP Domain", async () => { @@ -127,12 +134,12 @@ describe("Circle Integration -- Localnet", () => { }, }); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); - await expectIxErr(connection, [ix], [payer], "InvalidCctpDomain"); + await expectIxErr(connection, [ix], [payer], "Error Code: InvalidCctpDomain"); }); it("Invoke `register_emitter_and_domain`", async () => { @@ -151,12 +158,12 @@ describe("Circle Integration -- Localnet", () => { }, }); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); - const registeredEmitter = wormholeCctp.registeredEmitterAddress(foreignChain); + const registeredEmitter = circleIntegration.registeredEmitterAddress(foreignChain); // Verify that account does not exist before invoking ix. { @@ -168,7 +175,7 @@ describe("Circle Integration -- Localnet", () => { // Now check account contents. const registeredEmitterData = - await wormholeCctp.fetchRegisteredEmitter(registeredEmitter); + await circleIntegration.fetchRegisteredEmitter(registeredEmitter); expect(registeredEmitterData).to.eql({ bump: 255, cctpDomain, @@ -177,18 +184,32 @@ describe("Circle Integration -- Localnet", () => { }); localVariables.set("vaa", vaa); + localVariables.set("registeredEmitter", registeredEmitter); }); it("Cannot Invoke `register_emitter_and_domain` with Same Governance Sequence", async () => { const vaa = localVariables.get("vaa") as anchor.web3.PublicKey; expect(localVariables.delete("vaa")).is.true; - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const registeredEmitter = localVariables.get( + "registeredEmitter", + ) as anchor.web3.PublicKey; + expect(localVariables.delete("registeredEmitter")).is.true; + + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); - await expectIxErr(connection, [ix], [payer], "already in use"); + // NOTE: This error actually triggers because a registered emitter is already present. + // In case something changes with registration, we will keep this test around (it could + // fail if registration changes in the future). + await expectIxErr( + connection, + [ix], + [payer], + `Allocate: account Address { address: ${registeredEmitter.toString()}, base: None } already in use`, + ); }); it("Cannot Invoke `register_emitter_and_domain` with Updated Emitter on Same Chain", async () => { @@ -211,23 +232,28 @@ describe("Circle Integration -- Localnet", () => { }, }); - const ix = await wormholeCctp.registerEmitterAndDomainIx({ + const ix = await circleIntegration.registerEmitterAndDomainIx({ payer: payer.publicKey, vaa, }); - const registeredEmtiter = wormholeCctp.registeredEmitterAddress(foreignChain); + const registeredEmtiter = circleIntegration.registeredEmitterAddress(foreignChain); // Show that the foreign emitter about to be registered is not already written to the // account. { - const currentForeignEmitter = await wormholeCctp + const currentForeignEmitter = await circleIntegration .fetchRegisteredEmitter(registeredEmtiter) .then((registered) => registered.address); expect(currentForeignEmitter).not.eql(foreignEmitter); } - await expectIxErr(connection, [ix], [payer], "already in use"); + await expectIxErr( + connection, + [ix], + [payer], + `Allocate: account Address { address: ${registeredEmtiter.toString()}, base: None } already in use`, + ); }); }); @@ -244,7 +270,7 @@ describe("Circle Integration -- Localnet", () => { const inputPayload = Buffer.from("All your base are belong to us."); const message = anchor.web3.Keypair.generate(); - const ix = await wormholeCctp.transferTokensWithPayloadIx( + const ix = await circleIntegration.transferTokensWithPayloadIx( { payer: payer.publicKey, mint: USDC_MINT_ADDRESS, @@ -260,14 +286,27 @@ describe("Circle Integration -- Localnet", () => { }, ); + const approveIx = splToken.createApproveInstruction( + payerToken, + circleIntegration.custodianAddress(), + payer.publicKey, + 1, + ); + const lookupTableAccount = await connection .getAddressLookupTable(lookupTableAddress) .then((resp) => resp.value); /// NOTE: This is a CCTP Token Messenger Minter program error. - await expectIxErr(connection, [ix], [payer, message], "InvalidAmount", { - addressLookupTableAccounts: [lookupTableAccount], - }); + await expectIxErr( + connection, + [approveIx, ix], + [payer, message], + "Error Code: InvalidAmount", + { + addressLookupTableAccounts: [lookupTableAccount], + }, + ); }); it("Cannot Invoke `transfer_tokens_with_payload` with Invalid Mint Recipient", async () => { @@ -282,7 +321,7 @@ describe("Circle Integration -- Localnet", () => { const inputPayload = Buffer.from("All your base are belong to us."); const message = anchor.web3.Keypair.generate(); - const ix = await wormholeCctp.transferTokensWithPayloadIx( + const ix = await circleIntegration.transferTokensWithPayloadIx( { payer: payer.publicKey, mint: USDC_MINT_ADDRESS, @@ -298,25 +337,35 @@ describe("Circle Integration -- Localnet", () => { }, ); + const approveIx = splToken.createApproveInstruction( + payerToken, + circleIntegration.custodianAddress(), + payer.publicKey, + amount, + ); + const lookupTableAccount = await connection .getAddressLookupTable(lookupTableAddress) .then((resp) => resp.value); /// NOTE: This is a CCTP Token Messenger Minter program error. - await expectIxErr(connection, [ix], [payer, message], "InvalidMintRecipient", { - addressLookupTableAccounts: [lookupTableAccount], - }); + await expectIxErr( + connection, + [approveIx, ix], + [payer, message], + "Error Code: InvalidMintRecipient", + { + addressLookupTableAccounts: [lookupTableAccount], + }, + ); }); - it("Cannot Invoke `transfer_tokens_with_payload` if Burn Sender Not Authority for Burn Source", async () => { + it("Cannot Invoke `transfer_tokens_with_payload` if Custodian Not Delegated Authority", async () => { const payerToken = splToken.getAssociatedTokenAddressSync( USDC_MINT_ADDRESS, payer.publicKey, ); - // Create another sender authority. - const sender = anchor.web3.Keypair.generate(); - const amount = 69n; const targetChain = 2; const mintRecipient = Array.from(Buffer.alloc(32, "deadbeef", "hex")); @@ -324,13 +373,12 @@ describe("Circle Integration -- Localnet", () => { const inputPayload = Buffer.from("All your base are belong to us."); const message = anchor.web3.Keypair.generate(); - const ix = await wormholeCctp.transferTokensWithPayloadIx( + const ix = await circleIntegration.transferTokensWithPayloadIx( { payer: payer.publicKey, mint: USDC_MINT_ADDRESS, burnSource: payerToken, coreMessage: message.publicKey, - burnSourceAuthority: sender.publicKey, }, { amount, @@ -346,7 +394,7 @@ describe("Circle Integration -- Localnet", () => { .then((resp) => resp.value); // NOTE: This is an SPL Token program error. - await expectIxErr(connection, [ix], [payer, sender, message], "owner does not match", { + await expectIxErr(connection, [ix], [payer, message], "Error: owner does not match", { addressLookupTableAccounts: [lookupTableAccount], }); }); @@ -364,7 +412,7 @@ describe("Circle Integration -- Localnet", () => { const inputPayload = Buffer.from("All your base are belong to us."); const message = anchor.web3.Keypair.generate(); - const ix = await wormholeCctp.transferTokensWithPayloadIx( + const ix = await circleIntegration.transferTokensWithPayloadIx( { payer: payer.publicKey, mint: USDC_MINT_ADDRESS, @@ -380,6 +428,13 @@ describe("Circle Integration -- Localnet", () => { }, ); + const approveIx = splToken.createApproveInstruction( + payerToken, + circleIntegration.custodianAddress(), + payer.publicKey, + amount, + ); + const balanceBefore = await splToken .getAccount(connection, payerToken) .then((token) => token.amount); @@ -387,9 +442,14 @@ describe("Circle Integration -- Localnet", () => { const lookupTableAccount = await connection .getAddressLookupTable(lookupTableAddress) .then((resp) => resp.value); - const txReceipt = await expectIxOkDetails(connection, [ix], [payer, message], { - addressLookupTableAccounts: [lookupTableAccount], - }); + const txReceipt = await expectIxOkDetails( + connection, + [approveIx, ix], + [payer, message], + { + addressLookupTableAccounts: [lookupTableAccount], + }, + ); // Balance check. const balanceAfter = await splToken @@ -402,7 +462,7 @@ describe("Circle Integration -- Localnet", () => { const { deposit, payload } = Deposit.decode(posted.message.payload); expect(payload).to.eql(inputPayload); - const parsedTxData = await wormholeCctp.parseTransactionReceipt(txReceipt, [ + const parsedTxData = await circleIntegration.parseTransactionReceipt(txReceipt, [ lookupTableAccount, ]); expect(parsedTxData).has.length(1); @@ -412,7 +472,7 @@ describe("Circle Integration -- Localnet", () => { const burnMessage = CctpTokenBurnMessage.decode(txData.encodedCctpMessage); expect(burnMessage.sender).to.eql( - Array.from(wormholeCctp.custodianAddress().toBuffer()), + Array.from(circleIntegration.custodianAddress().toBuffer()), ); expect(burnMessage.mintRecipient).to.eql(mintRecipient); @@ -435,112 +495,11 @@ describe("Circle Integration -- Localnet", () => { payloadLen: inputPayload.length, } as DepositHeader); - const foreignEmitter = await wormholeCctp - .fetchRegisteredEmitter(wormholeCctp.registeredEmitterAddress(targetChain)) + const foreignEmitter = await circleIntegration + .fetchRegisteredEmitter(circleIntegration.registeredEmitterAddress(targetChain)) .then((registered) => registered.address); expect(targetCaller).to.eql(foreignEmitter); }); - - it("Invoke `transfer_tokens_with_payload` Multiple Times in One Transaction", async () => { - const payerToken = splToken.getAssociatedTokenAddressSync( - USDC_MINT_ADDRESS, - payer.publicKey, - ); - - const amount = 69n; - const targetChain = 2; - const mintRecipient = Array.from(Buffer.alloc(32, "deadbeef", "hex")); - const wormholeMessageNonce = 420; - const inputPayload = Buffer.from("Boop."); - - const messages = [ - anchor.web3.Keypair.generate(), - anchor.web3.Keypair.generate(), - anchor.web3.Keypair.generate(), - ]; - const ixs = await Promise.all( - messages.map((message) => - wormholeCctp.transferTokensWithPayloadIx( - { - payer: payer.publicKey, - mint: USDC_MINT_ADDRESS, - burnSource: payerToken, - coreMessage: message.publicKey, - }, - { - amount, - targetChain, - mintRecipient, - wormholeMessageNonce, - payload: inputPayload, - }, - ), - ), - ); - - const balanceBefore = await splToken - .getAccount(connection, payerToken) - .then((token) => token.amount); - - const lookupTableAccount = await connection - .getAddressLookupTable(lookupTableAddress) - .then((resp) => resp.value); - const txReceipt = await expectIxOkDetails(connection, ixs, [payer].concat(messages), { - addressLookupTableAccounts: [lookupTableAccount], - }); - - // Balance check. - const balanceAfter = await splToken - .getAccount(connection, payerToken) - .then((token) => token.amount); - expect(balanceAfter + BigInt(messages.length) * amount).to.equal(balanceBefore); - - const parsedTxData = await wormholeCctp.parseTransactionReceipt(txReceipt, [ - lookupTableAccount, - ]); - expect(parsedTxData).has.length(messages.length); - - const foreignEmitter = await wormholeCctp - .fetchRegisteredEmitter(wormholeCctp.registeredEmitterAddress(targetChain)) - .then((registered) => registered.address); - - for (let i = 0; i < messages.length; ++i) { - const txData = parsedTxData[i]; - const message = messages[i]; - expect(txData.coreMessageAccount).is.eql(message.publicKey); - - // Check messages. - const posted = await getPostedMessage(connection, message.publicKey); - const { deposit, payload } = Deposit.decode(posted.message.payload); - expect(payload).to.eql(inputPayload); - - const burnMessage = CctpTokenBurnMessage.decode(txData.encodedCctpMessage); - expect(burnMessage.sender).to.eql( - Array.from(wormholeCctp.custodianAddress().toBuffer()), - ); - expect(burnMessage.mintRecipient).to.eql(mintRecipient); - - const { - cctp: { - sourceDomain: sourceCctpDomain, - destinationDomain: destinationCctpDomain, - nonce: cctpNonce, - targetCaller, - }, - } = burnMessage; - expect(deposit).to.eql({ - tokenAddress: Array.from(USDC_MINT_ADDRESS.toBuffer()), - amount, - sourceCctpDomain, - destinationCctpDomain, - cctpNonce, - burnSource: Array.from(payerToken.toBuffer()), - mintRecipient, - payloadLen: inputPayload.length, - } as DepositHeader); - expect(targetCaller).to.eql(foreignEmitter); - } - }); }); describe("Inbound Transfers", () => { @@ -571,7 +530,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { burnMessage, destinationCctpDomain, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -605,7 +564,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -649,7 +608,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -686,7 +645,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -730,7 +689,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -764,7 +723,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -779,7 +738,7 @@ describe("Circle Integration -- Localnet", () => { connection, [computeIx, ix], [payer, mintRecipientAuthority], - "InvalidMintRecipient", + "Error Code: InvalidMintRecipient", { addressLookupTableAccounts: [lookupTableAccount], }, @@ -806,7 +765,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -840,7 +799,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -856,7 +815,7 @@ describe("Circle Integration -- Localnet", () => { connection, [computeIx, ix], [payer, someoneElse], - "ConstraintTokenOwner", + "Error Code: ConstraintTokenOwner", { addressLookupTableAccounts: [lookupTableAccount], }, @@ -881,7 +840,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -915,7 +874,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -930,7 +889,7 @@ describe("Circle Integration -- Localnet", () => { connection, [computeIx, ix], [payer, mintRecipientAuthority], - "SourceCctpDomainMismatch", + "Error Code: SourceCctpDomainMismatch", { addressLookupTableAccounts: [lookupTableAccount], }, @@ -955,7 +914,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -989,7 +948,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -1004,7 +963,7 @@ describe("Circle Integration -- Localnet", () => { connection, [computeIx, ix], [payer, mintRecipientAuthority], - "DestinationCctpDomainMismatch", + "Error Code: DestinationCctpDomainMismatch", { addressLookupTableAccounts: [lookupTableAccount], }, @@ -1029,7 +988,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -1063,7 +1022,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -1078,7 +1037,7 @@ describe("Circle Integration -- Localnet", () => { connection, [computeIx, ix], [payer, mintRecipientAuthority], - "CctpNonceMismatch", + "Error Code: CctpNonceMismatch", { addressLookupTableAccounts: [lookupTableAccount], }, @@ -1103,7 +1062,7 @@ describe("Circle Integration -- Localnet", () => { const burnSource = Array.from(Buffer.alloc(32, "beefdead", "hex")); const { destinationCctpDomain, burnMessage, encodedCctpMessage, cctpAttestation } = await craftCctpTokenBurnMessage( - wormholeCctp, + circleIntegration, sourceCctpDomain, cctpNonce, encodedMintRecipient, @@ -1137,7 +1096,7 @@ describe("Circle Integration -- Localnet", () => { const computeIx = anchor.web3.ComputeBudgetProgram.setComputeUnitLimit({ units: 250_000, }); - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -1183,7 +1142,7 @@ describe("Circle Integration -- Localnet", () => { ) as anchor.web3.Keypair; expect(localVariables.delete("mintRecipientAuthority")).is.true; - const ix = await wormholeCctp.redeemTokensWithPayloadIx( + const ix = await circleIntegration.redeemTokensWithPayloadIx( { payer: payer.publicKey, vaa, @@ -1196,14 +1155,14 @@ describe("Circle Integration -- Localnet", () => { connection, [ix], [payer, mintRecipientAuthority], - "NonceAlreadyUsed", + "Error Code: NonceAlreadyUsed", ); }); }); }); async function craftCctpTokenBurnMessage( - wormholeCctp: WormholeCctpProgram, + circleIntegration: CircleIntegrationProgram, sourceCctpDomain: number, cctpNonce: bigint, encodedMintRecipient: number[], @@ -1213,13 +1172,13 @@ async function craftCctpTokenBurnMessage( ) { const { destinationCctpDomain: inputDestinationCctpDomain } = overrides; - const messageTransmitterProgram = wormholeCctp.messageTransmitterProgram(); + const messageTransmitterProgram = circleIntegration.messageTransmitterProgram(); const { version, localDomain } = await messageTransmitterProgram.fetchMessageTransmitterConfig( messageTransmitterProgram.messageTransmitterConfigAddress(), ); const destinationCctpDomain = inputDestinationCctpDomain ?? localDomain; - const tokenMessengerMinterProgram = wormholeCctp.tokenMessengerMinterProgram(); + const tokenMessengerMinterProgram = circleIntegration.tokenMessengerMinterProgram(); const sourceTokenMessenger = await tokenMessengerMinterProgram .fetchRemoteTokenMessenger( tokenMessengerMinterProgram.remoteTokenMessengerAddress(sourceCctpDomain), @@ -1234,7 +1193,7 @@ async function craftCctpTokenBurnMessage( nonce: cctpNonce, sender: sourceTokenMessenger, recipient: Array.from(tokenMessengerMinterProgram.ID.toBuffer()), // targetTokenMessenger - targetCaller: Array.from(wormholeCctp.custodianAddress().toBuffer()), // targetCaller + targetCaller: Array.from(circleIntegration.custodianAddress().toBuffer()), // targetCaller }, 0, Array.from(wormholeSdk.tryNativeToUint8Array(ETHEREUM_USDC_ADDRESS, "ethereum")), // sourceTokenAddress diff --git a/solana/ts/tests/10__testnetFork.ts b/solana/ts/tests/10__testnetFork.ts index e92494e..084e578 100644 --- a/solana/ts/tests/10__testnetFork.ts +++ b/solana/ts/tests/10__testnetFork.ts @@ -1,7 +1,7 @@ import { MockEmitter, MockGuardians } from "@certusone/wormhole-sdk/lib/cjs/mock"; import * as anchor from "@coral-xyz/anchor"; import { expect } from "chai"; -import { WormholeCctpProgram } from "../src"; +import { CircleIntegrationProgram } from "../src"; import { GUARDIAN_KEY, PAYER_PRIVATE_KEY, @@ -22,7 +22,7 @@ describe("Circle Integration -- Testnet Fork", () => { const connection = new anchor.web3.Connection("http://localhost:8899", "processed"); const payer = anchor.web3.Keypair.fromSecretKey(PAYER_PRIVATE_KEY); - const wormholeCctp = new WormholeCctpProgram( + const circleIntegration = new CircleIntegrationProgram( connection, "wCCTPvsyeL9qYqbHTv3DUAyzEfYcyHoYw5c4mgcbBeW", ); @@ -33,7 +33,7 @@ describe("Circle Integration -- Testnet Fork", () => { it("Deploy Implementation", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); localVariables.set("implementation", implementation); @@ -59,7 +59,7 @@ describe("Circle Integration -- Testnet Fork", () => { }, ); - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, }); @@ -70,7 +70,7 @@ describe("Circle Integration -- Testnet Fork", () => { it("Deploy Same Implementation and Invoke `upgrade_contract` with Another VAA", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); const vaa = await postGovVaa( @@ -89,7 +89,7 @@ describe("Circle Integration -- Testnet Fork", () => { }, ); - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, }); @@ -104,22 +104,25 @@ describe("Circle Integration -- Testnet Fork", () => { const vaa = localVariables.get("vaa") as anchor.web3.PublicKey; expect(localVariables.delete("vaa")).is.true; - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, }); + // NOTE: The claim account created in the upgrade contract instruction doesn't trigger + // the protection for a replay attack. The account data in the program data does. But + // we will keep this test here just in case something changes in the future. await expectIxErr(connection, [ix], [payer], "invalid account data for instruction"); }); it("Cannot Invoke `upgrade_contract` with Implementation Mismatch", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); const anotherImplementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); const vaa = await postGovVaa( @@ -139,24 +142,24 @@ describe("Circle Integration -- Testnet Fork", () => { ); // Create the upgrade instruction, but pass a different implementation. - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, buffer: implementation, }); - await expectIxErr(connection, [ix], [payer], "ImplementationMismatch"); + await expectIxErr(connection, [ix], [payer], "Error Code: ImplementationMismatch"); }); it("Cannot Invoke `upgrade_contract` with Invalid Governance Emitter", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); // Create a bad governance emitter by using an invalid address. const invalidEmitter = new MockEmitter( - wormholeCctp.ID.toBuffer().toString("hex"), + circleIntegration.ID.toBuffer().toString("hex"), 1, 12121212, ); @@ -179,19 +182,19 @@ describe("Circle Integration -- Testnet Fork", () => { ); // Create the upgrade instruction, but pass a different implementation. - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, buffer: implementation, }); - await expectIxErr(connection, [ix], [payer], "InvalidGovernanceEmitter"); + await expectIxErr(connection, [ix], [payer], "Error Code: InvalidGovernanceEmitter"); }); it("Cannot Invoke `upgrade_contract` with Governance For Another Chain", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); const vaa = await postGovVaa( @@ -210,18 +213,18 @@ describe("Circle Integration -- Testnet Fork", () => { }, ); - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, }); - await expectIxErr(connection, [ix], [payer], "GovernanceForAnotherChain"); + await expectIxErr(connection, [ix], [payer], "Error Code: GovernanceForAnotherChain"); }); it("Cannot Invoke `upgrade_contract` with Invalid Governance Action", async () => { const implementation = await loadProgramBpf( ARTIFACTS_PATH, - wormholeCctp.upgradeAuthorityAddress(), + circleIntegration.upgradeAuthorityAddress(), ); const vaa = await postGovVaa( @@ -247,13 +250,13 @@ describe("Circle Integration -- Testnet Fork", () => { }, ); - const ix = await wormholeCctp.upgradeContractIx({ + const ix = await circleIntegration.upgradeContractIx({ payer: payer.publicKey, vaa, buffer: implementation, }); - await expectIxErr(connection, [ix], [payer], "InvalidGovernanceAction"); + await expectIxErr(connection, [ix], [payer], "Error Code: InvalidGovernanceAction"); }); }); }); diff --git a/solana/ts/tests/helpers/utils.ts b/solana/ts/tests/helpers/utils.ts index 4686e76..55b3579 100644 --- a/solana/ts/tests/helpers/utils.ts +++ b/solana/ts/tests/helpers/utils.ts @@ -181,8 +181,8 @@ export async function loadProgramBpf( ); // Sometimes the validator fails to fetch a blockhash after this buffer gets loaded, so we wait - // a bit to ensure that doesn't happen. - await new Promise((resolve) => setTimeout(resolve, 5000)); + // a bit to ensure that doesn't happen. Uncomment this in if this is an issue. + //await new Promise((resolve) => setTimeout(resolve, 5000)); // Return the pubkey for the buffer (our new program implementation). return buffer;