(idl, provider);
+const program = getSpokePoolProgram(provider);
const programId = program.programId;
// Parse arguments
diff --git a/src/DeploymentUtils.ts b/src/DeploymentUtils.ts
index 58844ac45..b822aa933 100644
--- a/src/DeploymentUtils.ts
+++ b/src/DeploymentUtils.ts
@@ -6,7 +6,11 @@ interface DeploymentExport {
const deployments: DeploymentExport = deployments_ as any;
// Returns the deployed address of any contract on any network.
-export function getDeployedAddress(contractName: string, networkId: number, throwOnError = true): string | undefined {
+export function getDeployedAddress(
+ contractName: string,
+ networkId: number | string,
+ throwOnError = true
+): string | undefined {
const address = deployments[networkId.toString()]?.[contractName]?.address;
if (!address && throwOnError) {
throw new Error(`Contract ${contractName} not found on ${networkId} in deployments.json`);
diff --git a/src/svm/helpers.ts b/src/svm/helpers.ts
index 54a9d518e..7b737280d 100644
--- a/src/svm/helpers.ts
+++ b/src/svm/helpers.ts
@@ -7,7 +7,7 @@ import { ethers } from "ethers";
*/
export const getSolanaChainId = (cluster: "devnet" | "mainnet"): BigNumber => {
return BigNumber.from(
- BigInt(ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`solana-${cluster}`))) & BigInt("0xFFFFFFFFFFFFFFFF")
+ BigInt(ethers.utils.keccak256(ethers.utils.toUtf8Bytes(`solana-${cluster}`))) & BigInt("0xFFFFFFFFFFFF")
);
};
diff --git a/src/svm/index.ts b/src/svm/index.ts
index 614baeef3..8276197e4 100644
--- a/src/svm/index.ts
+++ b/src/svm/index.ts
@@ -4,6 +4,8 @@ export * from "./conversionUtils";
export * from "./transactionUtils";
export * from "./solanaProgramUtils";
export * from "./coders";
+export * from "./programConnectors";
+export * from "./assets";
export * from "./constants";
export * from "./helpers";
export * from "./cctpHelpers";
diff --git a/src/svm/programConnectors.ts b/src/svm/programConnectors.ts
new file mode 100644
index 000000000..ddcfeefae
--- /dev/null
+++ b/src/svm/programConnectors.ts
@@ -0,0 +1,61 @@
+import { AnchorProvider, Idl, Program } from "@coral-xyz/anchor";
+import { getDeployedAddress } from "../DeploymentUtils";
+import { SupportedNetworks } from "../types/svm";
+import {
+ MessageTransmitterAnchor,
+ MessageTransmitterIdl,
+ MulticallHandlerAnchor,
+ MulticallHandlerIdl,
+ SvmSpokeAnchor,
+ SvmSpokeIdl,
+ TokenMessengerMinterAnchor,
+ TokenMessengerMinterIdl,
+} from "./assets";
+import { getSolanaChainId, isSolanaDevnet } from "./helpers";
+
+type ProgramOptions = { network?: SupportedNetworks; programId?: string };
+
+export function getConnectedProgram(idl: P, provider: AnchorProvider, programId: string) {
+ idl.address = programId;
+ return new Program
(idl, provider);
+}
+
+// Resolves the program ID from options or defaults to the deployed address. Prioritizes programId, falls back to
+// network, and if network is not defined, determines the network from the provider's RPC URL. Throws an error if
+// the program ID cannot be resolved.
+function resolveProgramId(programName: string, provider: AnchorProvider, options?: ProgramOptions): string {
+ const { network, programId } = options ?? {};
+
+ if (programId) {
+ return programId; // Prioritize explicitly provided programId
+ }
+
+ const resolvedNetwork = network ?? (isSolanaDevnet(provider) ? "devnet" : "mainnet");
+ const deployedAddress = getDeployedAddress(programName, getSolanaChainId(resolvedNetwork).toString());
+
+ if (!deployedAddress) {
+ throw new Error(`${programName} Program ID not found for ${resolvedNetwork}`);
+ }
+
+ return deployedAddress;
+}
+
+export function getSpokePoolProgram(provider: AnchorProvider, options?: ProgramOptions) {
+ const id = resolveProgramId("SvmSpoke", provider, options);
+ return getConnectedProgram(SvmSpokeIdl, provider, id);
+}
+
+export function getMessageTransmitterProgram(provider: AnchorProvider, options?: ProgramOptions) {
+ const id = resolveProgramId("MessageTransmitter", provider, options);
+ return getConnectedProgram(MessageTransmitterIdl, provider, id);
+}
+
+export function getTokenMessengerMinterProgram(provider: AnchorProvider, options?: ProgramOptions) {
+ const id = resolveProgramId("TokenMessengerMinter", provider, options);
+ return getConnectedProgram(TokenMessengerMinterIdl, provider, id);
+}
+
+export function getMulticallHandlerProgram(provider: AnchorProvider, options?: ProgramOptions) {
+ const id = resolveProgramId("MulticallHandler", provider, options);
+ return getConnectedProgram(MulticallHandlerIdl, provider, id);
+}
diff --git a/src/types/svm.ts b/src/types/svm.ts
index 63b26d27a..e181eec29 100644
--- a/src/types/svm.ts
+++ b/src/types/svm.ts
@@ -131,3 +131,8 @@ export interface EventType {
blockTime: number;
signature: string;
}
+
+/**
+ * Supported Networks
+ */
+export type SupportedNetworks = "mainnet" | "devnet";
diff --git a/test/evm/hardhat/fixtures/HubPool.Fixture.ts b/test/evm/hardhat/fixtures/HubPool.Fixture.ts
index 6c4021354..9fee69b90 100644
--- a/test/evm/hardhat/fixtures/HubPool.Fixture.ts
+++ b/test/evm/hardhat/fixtures/HubPool.Fixture.ts
@@ -8,6 +8,9 @@ export const hubPoolFixture = hre.deployments.createFixture(async ({ ethers }) =
return await deployHubPool(ethers);
});
+// Silence warnings from openzeppelin/hardhat-upgrades for this fixture.
+hre.upgrades.silenceWarnings();
+
export async function deployHubPool(ethers: any, spokePoolName = "MockSpokePool") {
const [signer, crossChainAdmin] = await ethers.getSigners();
diff --git a/test/evm/hardhat/fixtures/SpokePool.Fixture.ts b/test/evm/hardhat/fixtures/SpokePool.Fixture.ts
index 277575861..8ea3cb0ec 100644
--- a/test/evm/hardhat/fixtures/SpokePool.Fixture.ts
+++ b/test/evm/hardhat/fixtures/SpokePool.Fixture.ts
@@ -16,6 +16,9 @@ export const spokePoolFixture = hre.deployments.createFixture(async ({ ethers })
return await deploySpokePool(ethers);
});
+// Silence warnings from openzeppelin/hardhat-upgrades for this fixture.
+hre.upgrades.silenceWarnings();
+
// Have a separate function that deploys the contract and returns the contract addresses. This is called by the fixture
// to have standard fixture features. It is also exported as a function to enable non-snapshoted deployments.
export async function deploySpokePool(
diff --git a/test/svm/SvmSpoke.Bundle.ts b/test/svm/SvmSpoke.Bundle.ts
index e84f9f36f..d1707e1ca 100644
--- a/test/svm/SvmSpoke.Bundle.ts
+++ b/test/svm/SvmSpoke.Bundle.ts
@@ -241,13 +241,23 @@ describe("svm_spoke.bundle", () => {
program: program.programId,
};
const proofAsNumbers = proof.map((p) => Array.from(p));
- await loadExecuteRelayerRefundLeafParams(program, owner, stateAccountData.rootBundleId, leaf, proofAsNumbers);
+ const instructionParams = await loadExecuteRelayerRefundLeafParams(
+ program,
+ owner,
+ stateAccountData.rootBundleId,
+ leaf,
+ proofAsNumbers
+ );
const tx = await program.methods
.executeRelayerRefundLeaf()
.accounts(executeRelayerRefundLeafAccounts)
.remainingAccounts(remainingAccounts)
.rpc();
+ // Verify the instruction params account has been automatically closed.
+ const instructionParamsInfo = await program.provider.connection.getAccountInfo(instructionParams);
+ assert.isNull(instructionParamsInfo, "Instruction params account should be closed");
+
// Verify the ExecutedRelayerRefundRoot event
let events = await readEventsUntilFound(connection, tx, [program]);
let event = events.find((event) => event.name === "executedRelayerRefundRoot")?.data;
diff --git a/test/svm/SvmSpoke.HandleReceiveMessage.ts b/test/svm/SvmSpoke.HandleReceiveMessage.ts
index ee783b487..82dd6d710 100644
--- a/test/svm/SvmSpoke.HandleReceiveMessage.ts
+++ b/test/svm/SvmSpoke.HandleReceiveMessage.ts
@@ -26,9 +26,8 @@ describe("svm_spoke.handle_receive_message", () => {
let usedNonces: web3.PublicKey;
let selfAuthority: web3.PublicKey;
let eventAuthority: web3.PublicKey;
- const firstNonce = 1;
const attestation = Buffer.alloc(0);
- let nonce = firstNonce;
+ let nonce = 0;
let remainingAccounts: web3.AccountMeta[];
const cctpMessageversion = 0;
let destinationCaller = new web3.PublicKey(new Uint8Array(32)); // We don't use permissioned caller.
@@ -57,10 +56,15 @@ describe("svm_spoke.handle_receive_message", () => {
[Buffer.from("message_transmitter")],
messageTransmitterProgram.programId
);
- [usedNonces] = web3.PublicKey.findProgramAddressSync(
- [Buffer.from("used_nonces"), Buffer.from(remoteDomain.toString()), Buffer.from(firstNonce.toString())],
- messageTransmitterProgram.programId
- );
+ usedNonces = await messageTransmitterProgram.methods
+ .getNoncePda({
+ nonce: new BN(nonce.toString()),
+ sourceDomain: remoteDomain.toNumber(),
+ })
+ .accounts({
+ messageTransmitter: messageTransmitterState,
+ })
+ .view();
[selfAuthority] = web3.PublicKey.findProgramAddressSync([Buffer.from("self_authority")], program.programId);
[eventAuthority] = web3.PublicKey.findProgramAddressSync([Buffer.from("__event_authority")], program.programId);
@@ -145,10 +149,15 @@ describe("svm_spoke.handle_receive_message", () => {
it("Block Wrong Source Domain", async () => {
const sourceDomain = 666;
- [receiveMessageAccounts.usedNonces] = web3.PublicKey.findProgramAddressSync(
- [Buffer.from("used_nonces"), Buffer.from(sourceDomain.toString()), Buffer.from(firstNonce.toString())],
- messageTransmitterProgram.programId
- );
+ receiveMessageAccounts.usedNonces = await messageTransmitterProgram.methods
+ .getNoncePda({
+ nonce: new BN(nonce.toString()),
+ sourceDomain,
+ })
+ .accounts({
+ messageTransmitter: messageTransmitterState,
+ })
+ .view();
const calldata = ethereumIface.encodeFunctionData("pauseDeposits", [true]);
const messageBody = Buffer.from(calldata.slice(2), "hex");
diff --git a/test/svm/SvmSpoke.Ownership.ts b/test/svm/SvmSpoke.Ownership.ts
index c9ff1c46c..644225f0a 100644
--- a/test/svm/SvmSpoke.Ownership.ts
+++ b/test/svm/SvmSpoke.Ownership.ts
@@ -124,8 +124,17 @@ describe("svm_spoke.ownership", () => {
it("Transfers ownership", async () => {
// Transfer ownership to newOwner
- const transferOwnershipAccounts = { state, signer: owner };
- await program.methods.transferOwnership(newOwner.publicKey).accounts(transferOwnershipAccounts).rpc();
+ const transferOwnershipAccounts = { state, signer: owner, program: program.programId };
+ const tx = await program.methods.transferOwnership(newOwner.publicKey).accounts(transferOwnershipAccounts).rpc();
+
+ // Verify the TransferredOwnership event
+ let events = await readEventsUntilFound(provider.connection, tx, [program]);
+ let transferredOwnershipEvents = events.filter((event) => event.name === "transferredOwnership");
+ assert.equal(
+ transferredOwnershipEvents[0].data.newOwner.toString(),
+ newOwner.publicKey.toString(),
+ "TransferredOwnership event should indicate the new owner"
+ );
// Verify the new owner
let stateAccountData = await program.account.state.fetch(state);
@@ -133,7 +142,7 @@ describe("svm_spoke.ownership", () => {
// Try to transfer ownership as non-owner
try {
- const transferOwnershipAccounts = { state, signer: nonOwner.publicKey };
+ const transferOwnershipAccounts = { state, signer: nonOwner.publicKey, program: program.programId };
await program.methods
.transferOwnership(nonOwner.publicKey)
.accounts(transferOwnershipAccounts)
diff --git a/test/svm/SvmSpoke.RefundClaims.ts b/test/svm/SvmSpoke.RefundClaims.ts
index 01c35ff09..dcb0e4890 100644
--- a/test/svm/SvmSpoke.RefundClaims.ts
+++ b/test/svm/SvmSpoke.RefundClaims.ts
@@ -4,7 +4,14 @@ import { Keypair, PublicKey } from "@solana/web3.js";
import { assert } from "chai";
import { common } from "./SvmSpoke.common";
import { MerkleTree } from "@uma/common/dist/MerkleTree";
-import { createMint, getOrCreateAssociatedTokenAccount, mintTo, TOKEN_PROGRAM_ID } from "@solana/spl-token";
+import {
+ AuthorityType,
+ createMint,
+ getOrCreateAssociatedTokenAccount,
+ mintTo,
+ setAuthority,
+ TOKEN_PROGRAM_ID,
+} from "@solana/spl-token";
import { RelayerRefundLeafSolana, RelayerRefundLeafType } from "../../src/types/svm";
import { loadExecuteRelayerRefundLeafParams, readEventsUntilFound, relayerRefundHashFn } from "../../src/svm";
@@ -31,6 +38,7 @@ describe("svm_spoke.refund_claims", () => {
state: PublicKey;
vault: PublicKey;
mint: PublicKey;
+ refundAddress: PublicKey;
tokenAccount: PublicKey;
claimAccount: PublicKey;
tokenProgram: PublicKey;
@@ -142,6 +150,7 @@ describe("svm_spoke.refund_claims", () => {
state,
vault,
mint,
+ refundAddress: relayer.publicKey,
tokenAccount,
claimAccount,
tokenProgram: TOKEN_PROGRAM_ID,
@@ -169,10 +178,7 @@ describe("svm_spoke.refund_claims", () => {
const iRelayerBal = (await connection.getTokenAccountBalance(tokenAccount)).value.amount;
// Claim refund for the relayer.
- const tx = await program.methods
- .claimRelayerRefundFor(relayer.publicKey)
- .accounts(claimRelayerRefundAccounts)
- .rpc();
+ const tx = await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
// The relayer should have received funds from the vault.
const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount;
@@ -194,11 +200,11 @@ describe("svm_spoke.refund_claims", () => {
await executeRelayerRefundToClaim(relayerRefund);
// Claim refund for the relayer.
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
// The claim account should have been automatically closed, so repeated claim should fail.
try {
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
assert.fail("Claiming refund from closed account should fail");
} catch (error: any) {
assert.instanceOf(error, AnchorError);
@@ -212,7 +218,7 @@ describe("svm_spoke.refund_claims", () => {
// After reinitalizing the claim account, the repeated claim should still fail.
await initializeClaimAccount();
try {
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
assert.fail("Claiming refund from reinitalized account should fail");
} catch (error: any) {
assert.instanceOf(error, AnchorError);
@@ -231,7 +237,7 @@ describe("svm_spoke.refund_claims", () => {
const iRelayerBal = (await connection.getTokenAccountBalance(tokenAccount)).value.amount;
// Claim refund for the relayer.
- await await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
// The relayer should have received both refunds.
const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount;
@@ -256,7 +262,7 @@ describe("svm_spoke.refund_claims", () => {
// Claiming with default initializer should fail.
try {
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
} catch (error: any) {
assert.instanceOf(error, AnchorError);
assert.strictEqual(
@@ -268,7 +274,7 @@ describe("svm_spoke.refund_claims", () => {
// Claim refund for the relayer passing the correct initializer account.
claimRelayerRefundAccounts.initializer = anotherInitializer.publicKey;
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
// The relayer should have received funds from the vault.
const fVaultBal = (await connection.getTokenAccountBalance(vault)).value.amount;
@@ -329,25 +335,50 @@ describe("svm_spoke.refund_claims", () => {
}
});
- it("Cannot claim refund on behalf of relayer to wrong token account", async () => {
+ it("Cannot claim refund on behalf of relayer to wrongly owned token account", async () => {
// Execute relayer refund using claim account.
const relayerRefund = new BN(500000);
await executeRelayerRefundToClaim(relayerRefund);
- // Claim refund for the relayer to a custom token account.
+ // Claim refund for the relayer to a custom token account owned by another authority.
const wrongOwner = Keypair.generate().publicKey;
const wrongTokenAccount = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, wrongOwner)).address;
claimRelayerRefundAccounts.tokenAccount = wrongTokenAccount;
try {
- await program.methods.claimRelayerRefundFor(relayer.publicKey).accounts(claimRelayerRefundAccounts).rpc();
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
+ assert.fail("Claiming refund to custom token account should fail");
+ } catch (error: any) {
+ assert.instanceOf(error, AnchorError);
+ assert.strictEqual(
+ error.error.errorCode.code,
+ "InvalidRefundTokenAccount",
+ "Expected error code InvalidRefundTokenAccount"
+ );
+ }
+ });
+
+ it("Cannot claim refund on behalf of relayer to wrong associated token account", async () => {
+ // Execute relayer refund using claim account.
+ const relayerRefund = new BN(500000);
+ await executeRelayerRefundToClaim(relayerRefund);
+
+ // Claim refund for the relayer to a custom token account owned by the relayer, but not being its associated token account.
+ const wrongOwner = Keypair.generate();
+ const wrongTokenAccount = (await getOrCreateAssociatedTokenAccount(connection, payer, mint, wrongOwner.publicKey))
+ .address;
+ claimRelayerRefundAccounts.tokenAccount = wrongTokenAccount;
+ await setAuthority(connection, payer, wrongTokenAccount, wrongOwner, AuthorityType.AccountOwner, relayer.publicKey);
+
+ try {
+ await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
assert.fail("Claiming refund to custom token account should fail");
} catch (error: any) {
assert.instanceOf(error, AnchorError);
assert.strictEqual(
error.error.errorCode.code,
- "ConstraintTokenOwner",
- "Expected error code ConstraintTokenOwner"
+ "InvalidRefundTokenAccount",
+ "Expected error code InvalidRefundTokenAccount"
);
}
});
@@ -389,6 +420,7 @@ describe("svm_spoke.refund_claims", () => {
await executeRelayerRefundToClaim(relayerRefund);
// Claim refund for the relayer with the default signer should fail as relayer address is part of claim account derivation.
+ claimRelayerRefundAccounts.refundAddress = owner;
try {
await program.methods.claimRelayerRefund().accounts(claimRelayerRefundAccounts).rpc();
assert.fail("Claiming refund with wrong signer should fail");
diff --git a/yarn.lock b/yarn.lock
index 3ae6d88a9..188a32aa4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,10 +2,10 @@
# yarn lockfile v1
-"@across-protocol/constants@^3.1.24":
- version "3.1.24"
- resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.24.tgz#01fe49330bb467dd01813387ddbac741bc74a035"
- integrity sha512-guKtvIbif//vsmSZbwGubTWVtfkWiyWenr2sVyo63U/68GOW89ceJRLu4efLjeLVGiSrNAJtFUCv9dTwrrosWA==
+"@across-protocol/constants@^3.1.30":
+ version "3.1.30"
+ resolved "https://registry.yarnpkg.com/@across-protocol/constants/-/constants-3.1.30.tgz#b5bb82b5efcf3f63658332eece240ecdb645c0bc"
+ integrity sha512-1lEhQmYiqcMKg05fnPfSeCk9QTRaHdVykD+Wcr5tcsyPYgOMtXOXvxxvtSOe9FK+ckpRypp4ab2WUN2iitnzpw==
"@across-protocol/contracts@^0.1.4":
version "0.1.4"