From addf6c39bb21ba98bb58fe682dd05b68e3239e61 Mon Sep 17 00:00:00 2001 From: rabi-siddique Date: Fri, 25 Jul 2025 09:39:41 +0500 Subject: [PATCH 1/4] refactor: add replay protection and chain validation to Factory execution logic --- package-lock.json | 4 +- .../src/__tests__/Factory.spec.ts | 13 ++- .../src/__tests__/contracts/Factory.sol | 91 +++++++------------ 3 files changed, 41 insertions(+), 67 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8963a58..f9cc44f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12628,9 +12628,7 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "extraneous": true, "os": [ - "darwin", - "win32", - "linux" + "darwin" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" diff --git a/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts b/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts index b9f313f0..8cc8fbe5 100644 --- a/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts +++ b/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts @@ -75,7 +75,6 @@ describe("Factory", () => { factory = await Contract.deploy( axelarGatewayMock.target, axelarGasServiceMock.target, - "Ethereum", ); await factory.waitForDeployment(); @@ -93,7 +92,7 @@ describe("Factory", () => { }); }); - it("fund Factory with ETH to pay for gas", async () => { + it("fund Factory with ETH", async () => { const provider = ethers.provider; const factoryAddress = await factory.getAddress(); @@ -116,7 +115,7 @@ describe("Factory", () => { return null; } }) - .find((parsed) => parsed && parsed.name === "Received"); + .find((parsed) => parsed && parsed.name === "TokensReceived"); expect(receivedEvent).to.not.be.undefined; expect(receivedEvent?.args.sender).to.equal(owner.address); @@ -129,7 +128,8 @@ describe("Factory", () => { it("should create a new remote wallet using Factory", async () => { const commandId = getCommandId(); - const payload = abiCoder.encode(["uint256"], [50000]); + const nonce = 1; + const payload = abiCoder.encode(["uint256"], [nonce]); const payloadHash = keccak256(toBytes(payload)); await approveMessage({ @@ -151,9 +151,8 @@ describe("Factory", () => { ); await expect(tx) - .to.emit(factory, "SmartWalletCreated") - .withArgs(expectedWalletAddress, sourceAddress, "agoric", sourceAddress); - await expect(tx).to.emit(factory, "CrossChainCallSent"); + .to.emit(factory, "NewWalletCreated") + .withArgs(expectedWalletAddress, nonce, sourceAddress, "agoric"); }); it("should use the remote wallet to call other contracts", async () => { diff --git a/packages/axelar-local-dev-cosmos/src/__tests__/contracts/Factory.sol b/packages/axelar-local-dev-cosmos/src/__tests__/contracts/Factory.sol index 05031477..86ef05d8 100644 --- a/packages/axelar-local-dev-cosmos/src/__tests__/contracts/Factory.sol +++ b/packages/axelar-local-dev-cosmos/src/__tests__/contracts/Factory.sol @@ -87,87 +87,64 @@ contract Factory is AxelarExecutable { using StringToAddress for string; using AddressToString for address; - address _gateway; + address gatewayAddr; IAxelarGasService public immutable gasService; - string public chainName; - - event SmartWalletCreated( - address indexed wallet, - string owner, - string sourceChain, - string sourceAddress - ); - event CrossChainCallSent( - string destinationChain, - string destinationAddress, - bytes payload - ); - event Received(address indexed sender, uint256 amount); + // Tracks used nonces per source address to prevent replay attacks. + // TODO: Should we consider limiting or cleaning this mapping to avoid unbounded growth? + mapping(string => mapping(uint256 => bool)) public usedNonces; + bytes32 internal constant EXPECTED_SOURCE_CHAIN = keccak256("agoric"); constructor( address gateway_, - address gasReceiver_, - string memory chainName_ + address gasReceiver_ ) AxelarExecutable(gateway_) { gasService = IAxelarGasService(gasReceiver_); - _gateway = gateway_; - chainName = chainName_; + gatewayAddr = gateway_; } - function createSmartWallet(string memory owner) public returns (address) { - address newWallet = address( - new Wallet(_gateway, address(gasService), owner) - ); - return newWallet; + function createWallet(string memory owner) internal returns (address) { + return address(new Wallet(gatewayAddr, address(gasService), owner)); } + event NewWalletCreated( + address indexed wallet, + uint256 nonce, + string sourceAddress, + string sourceChain + ); + + /// @notice Executes a cross-chain wallet creation request. + /// @param sourceChain Name of the chain that sent the message + /// @param sourceAddress Address (string) of the sender from source chain + /// @param payload ABI-encoded nonce function _execute( string calldata sourceChain, string calldata sourceAddress, bytes calldata payload ) internal override { - (uint256 gasAmount) = abi.decode(payload, (uint256)); - address smartWalletAddress = createSmartWallet(sourceAddress); - emit SmartWalletCreated( - smartWalletAddress, - sourceAddress, - sourceChain, - sourceAddress - ); - CallResult[] memory results = new CallResult[](1); - - results[0] = CallResult(true, abi.encode(smartWalletAddress)); - - bytes memory msgPayload = abi.encodePacked( - bytes4(0x00000000), - abi.encode(AgoricResponse(false, results)) + require( + keccak256(bytes(sourceChain)) == EXPECTED_SOURCE_CHAIN, + "Only messages from Agoric chain are allowed" ); - _send(sourceChain, sourceAddress, msgPayload, gasAmount); - } - function _send( - string calldata destinationChain, - string calldata destinationAddress, - bytes memory payload, - uint256 gasAmount - ) internal { - gasService.payNativeGasForContractCall{value: gasAmount}( - address(this), - destinationChain, - destinationAddress, - payload, - address(this) + uint256 nonce = abi.decode(payload, (uint256)); + require( + !usedNonces[sourceAddress][nonce], + "nonce already used by sender" ); + usedNonces[sourceAddress][nonce] = true; - gateway.callContract(destinationChain, destinationAddress, payload); - emit CrossChainCallSent(destinationChain, destinationAddress, payload); + address wallet = createWallet(sourceAddress); + emit NewWalletCreated(wallet, nonce, sourceAddress, sourceChain); } + event TokensReceived(address indexed sender, uint256 amount, string method); + receive() external payable { - emit Received(msg.sender, msg.value); + emit TokensReceived(msg.sender, msg.value, "receive"); } fallback() external payable { - emit Received(msg.sender, msg.value); + emit TokensReceived(msg.sender, msg.value, "fallback"); } } From 6a2fb63a5a175794a00e16e01833a123c69ecdaa Mon Sep 17 00:00:00 2001 From: rabi-siddique Date: Fri, 25 Jul 2025 10:40:32 +0500 Subject: [PATCH 2/4] test: add test for nonce and source chain validation --- .github/workflows/agoric-integration.yml | 12 ++-- integration/scripts/setup-gmp.mjs | 2 +- package-lock.json | 4 +- .../src/__tests__/Factory.spec.ts | 64 +++++++++++++++++-- 4 files changed, 67 insertions(+), 15 deletions(-) diff --git a/.github/workflows/agoric-integration.yml b/.github/workflows/agoric-integration.yml index e737abc1..45ee9cdf 100644 --- a/.github/workflows/agoric-integration.yml +++ b/.github/workflows/agoric-integration.yml @@ -55,10 +55,10 @@ jobs: env: makeAccount: true - - name: Relay data from EVM chain - run: | - npm run relay:cosmos & - sleep 150 + # - name: Relay data from EVM chain + # run: | + # npm run relay:cosmos & + # sleep 150 - - name: Get EVM Smart wallet address - run: ./integration/scripts/make-account.mjs + # - name: Get EVM Smart wallet address + # run: ./integration/scripts/make-account.mjs diff --git a/integration/scripts/setup-gmp.mjs b/integration/scripts/setup-gmp.mjs index 0a473a69..008f2f4a 100755 --- a/integration/scripts/setup-gmp.mjs +++ b/integration/scripts/setup-gmp.mjs @@ -7,7 +7,7 @@ const { log } = console; const SDK_REPO = "https://github.com/Agoric/agoric-sdk.git"; const SDK_DIR = "/usr/src/agoric-sdk-cp"; -const BRANCH_NAME = "master"; +const BRANCH_NAME = "rs-use-nonce-in-axelar-gmp-contract"; const PLAN_FILE_DIR = "/usr/src/upgrade-test-scripts"; const vbankAssetUrl = "http://localhost/agoric-lcd/agoric/vstorage/data/published.agoricNames.vbankAsset"; diff --git a/package-lock.json b/package-lock.json index f9cc44f4..b8963a58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12628,7 +12628,9 @@ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", "extraneous": true, "os": [ - "darwin" + "darwin", + "win32", + "linux" ], "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" diff --git a/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts b/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts index 8cc8fbe5..b9d9ff70 100644 --- a/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts +++ b/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts @@ -32,7 +32,7 @@ describe("Factory", () => { const abiCoder = new ethers.AbiCoder(); const expectedWalletAddress = "0x856e4424f806D16E8CBC702B3c0F2ede5468eae5"; - const sourceContract = "agoric"; + const sourceChain = "agoric"; const sourceAddress = "0x1234567890123456789012345678901234567890"; let commandIdCounter = 1; @@ -134,7 +134,7 @@ describe("Factory", () => { await approveMessage({ commandId, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: factory.target, payload: payloadHash, @@ -145,7 +145,7 @@ describe("Factory", () => { const tx = await factory.execute( commandId, - sourceContract, + sourceChain, sourceAddress, payload, ); @@ -155,6 +155,56 @@ describe("Factory", () => { .withArgs(expectedWalletAddress, nonce, sourceAddress, "agoric"); }); + it("should revert if nonce is reused", async () => { + const commandId = getCommandId(); + + const nonce = 1; + const payload = abiCoder.encode(["uint256"], [nonce]); + const payloadHash = keccak256(toBytes(payload)); + + await approveMessage({ + commandId, + from: sourceChain, + sourceAddress, + targetAddress: factory.target, + payload: payloadHash, + owner, + AxelarGateway: axelarGatewayMock, + abiCoder, + }); + + await expect( + factory.execute(commandId, sourceChain, sourceAddress, payload), + ).to.be.revertedWith("nonce already used by sender"); + }); + + it("should revert if message comes from a chain besides agoric", async () => { + const commandId = getCommandId(); + const unsupportedSourceChain = "ethereum"; + const payload = abiCoder.encode(["uint256"], [2]); + const payloadHash = keccak256(toBytes(payload)); + + await approveMessage({ + commandId, + from: unsupportedSourceChain, + sourceAddress, + targetAddress: factory.target, + payload: payloadHash, + owner, + AxelarGateway: axelarGatewayMock, + abiCoder, + }); + + await expect( + factory.execute( + commandId, + unsupportedSourceChain, + sourceAddress, + payload, + ), + ).to.be.revertedWith("Only messages from Agoric chain are allowed"); + }); + it("should use the remote wallet to call other contracts", async () => { // Deploy Multicall.sol const MulticallFactory = await ethers.getContractFactory("Multicall"); @@ -187,7 +237,7 @@ describe("Factory", () => { const commandId1 = getCommandId(); await approveMessage({ commandId: commandId1, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: wallet.target, payload: payloadHash, @@ -198,7 +248,7 @@ describe("Factory", () => { const execTx = await wallet.execute( commandId1, - sourceContract, + sourceChain, sourceAddress, multicallPayload, ); @@ -221,7 +271,7 @@ describe("Factory", () => { const commandId2 = getCommandId(); await approveMessageWithToken({ commandId: commandId2, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: wallet.target, payload: payloadHash2, @@ -234,7 +284,7 @@ describe("Factory", () => { const execWithTokenTx = await wallet.executeWithToken( commandId2, - sourceContract, + sourceChain, sourceAddress, multicallPayload2, "USDC", From 32b5313a9919dc69ad2007bb2828ba03b2d390a6 Mon Sep 17 00:00:00 2001 From: rabi-siddique Date: Fri, 25 Jul 2025 14:42:02 +0500 Subject: [PATCH 3/4] chore: update integration test deployment and make offer script --- integration/scripts/make-account.mjs | 3 --- packages/axelar-local-dev-cosmos/src/relay.ts | 6 +----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/integration/scripts/make-account.mjs b/integration/scripts/make-account.mjs index 5e5d48e6..1cb4c879 100755 --- a/integration/scripts/make-account.mjs +++ b/integration/scripts/make-account.mjs @@ -27,9 +27,6 @@ try { brandName: "BLD", amount: 1n, source: "contract", - offerArgs: { - gasAmount: 0n, - }, }); await processWalletOffer({ diff --git a/packages/axelar-local-dev-cosmos/src/relay.ts b/packages/axelar-local-dev-cosmos/src/relay.ts index beeb69bb..14b3f216 100644 --- a/packages/axelar-local-dev-cosmos/src/relay.ts +++ b/packages/axelar-local-dev-cosmos/src/relay.ts @@ -24,11 +24,7 @@ export const relayBasic = async () => { const factoryContract = await deployContract( ethereumNetwork.userWallets[0], require("../artifacts/src/__tests__/contracts/Factory.sol/Factory.json"), - [ - ethereumNetwork.gateway.address, - ethereumNetwork.gasService.address, - "Ethereum", - ], + [ethereumNetwork.gateway.address, ethereumNetwork.gasService.address], ); console.log("Factory Contract Address:", factoryContract.address); From dd645c5b08788d3089f0d4291b8250265abe84b3 Mon Sep 17 00:00:00 2001 From: rabi-siddique Date: Fri, 25 Jul 2025 16:22:49 +0500 Subject: [PATCH 4/4] test: update integration test to verify new wallet address --- .github/workflows/agoric-integration.yml | 11 +- integration/scripts/make-account.mjs | 107 +++--------------- packages/axelar-local-dev-cosmos/src/relay.ts | 39 ++++++- 3 files changed, 58 insertions(+), 99 deletions(-) diff --git a/.github/workflows/agoric-integration.yml b/.github/workflows/agoric-integration.yml index 45ee9cdf..4ed62e4d 100644 --- a/.github/workflows/agoric-integration.yml +++ b/.github/workflows/agoric-integration.yml @@ -52,13 +52,6 @@ jobs: - name: Make offer for creating EVM Smart Wallet run: ./integration/scripts/make-account.mjs - env: - makeAccount: true - # - name: Relay data from EVM chain - # run: | - # npm run relay:cosmos & - # sleep 150 - - # - name: Get EVM Smart wallet address - # run: ./integration/scripts/make-account.mjs + - name: Relay data from EVM chain + run: npm run relay:cosmos diff --git a/integration/scripts/make-account.mjs b/integration/scripts/make-account.mjs index 1cb4c879..5ef90e2e 100755 --- a/integration/scripts/make-account.mjs +++ b/integration/scripts/make-account.mjs @@ -1,102 +1,31 @@ #!/usr/bin/env node // @ts-check import "./lockdown.mjs"; -import { - fetchFromVStorage, - poll, - prepareOffer, - processWalletOffer, - validateEvmAddress, -} from "./utils.mjs"; +import { prepareOffer, processWalletOffer } from "./utils.mjs"; const OFFER_FILE = "offer.json"; const CONTAINER_PATH = `/usr/src/${OFFER_FILE}`; const FROM_ADDRESS = "agoric1rwwley550k9mmk6uq6mm6z4udrg8kyuyvfszjk"; -const vStorageUrl = `http://localhost/agoric-lcd/agoric/vstorage/data/published.wallet.${FROM_ADDRESS}`; -const { makeAccount } = process.env; const { log, error } = console; try { - if (makeAccount) { - log("--- Creating and Monitoring LCA ---"); - - log("Preparing offer..."); - const offer = await prepareOffer({ - publicInvitationMaker: "createAndMonitorLCA", - instanceName: "axelarGmp", - brandName: "BLD", - amount: 1n, - source: "contract", - }); - - await processWalletOffer({ - offer, - OFFER_FILE, - CONTAINER_PATH, - FROM_ADDRESS, - }); - } else { - log("--- Getting EVM Smart Wallet Address ---"); - - const methodName = "getRemoteAddress"; - const invitationArgs = harden([methodName, []]); - - log(`Fetching previous offer from ${vStorageUrl}.current`); - const { offerToUsedInvitation } = await fetchFromVStorage( - `${vStorageUrl}.current`, - ); - const previousOffer = offerToUsedInvitation[0][0]; - log(`Previous offer found: ${JSON.stringify(previousOffer)}`); - - log("Preparing offer..."); - const offer = await prepareOffer({ - invitationMakerName: "makeEVMTransactionInvitation", - instanceName: "axelarGmp", - emptyProposal: true, - source: "continuing", - invitationArgs, - previousOffer, - }); - - await processWalletOffer({ - offer, - OFFER_FILE, - CONTAINER_PATH, - FROM_ADDRESS, - }); - - const pollIntervalMs = 5000; // 5 seconds - const maxWaitMs = 2 * 60 * 1000; // 2 minutes - const valid = await poll({ - checkFn: async () => { - log(`Fetching offer result from ${vStorageUrl}`); - const offerData = await fetchFromVStorage(vStorageUrl); - log(`Offer data received: ${JSON.stringify(offerData)}`); - - let smartWalletAddress; - try { - smartWalletAddress = offerData?.status?.result; - } catch (err) { - log("Failed to parse offerData.status.result as JSON:", err); - } - - log(`Validating smart wallet address: ${smartWalletAddress}`); - validateEvmAddress(smartWalletAddress); - - log(`Smart wallet address: ${smartWalletAddress}`); - return true; - }, - pollIntervalMs, - maxWaitMs, - }); - - if (valid) { - console.log(`✅ Test passed`); - } else { - console.error(`❌ Test failed`); - process.exit(1); - } - } + log("--- Creating and Monitoring LCA ---"); + + log("Preparing offer..."); + const offer = await prepareOffer({ + publicInvitationMaker: "createAndMonitorLCA", + instanceName: "axelarGmp", + brandName: "BLD", + amount: 1n, + source: "contract", + }); + + await processWalletOffer({ + offer, + OFFER_FILE, + CONTAINER_PATH, + FROM_ADDRESS, + }); } catch (err) { error("ERROR:", err.shortMessage || err.message); process.exit(1); diff --git a/packages/axelar-local-dev-cosmos/src/relay.ts b/packages/axelar-local-dev-cosmos/src/relay.ts index 14b3f216..4ffe673e 100644 --- a/packages/axelar-local-dev-cosmos/src/relay.ts +++ b/packages/axelar-local-dev-cosmos/src/relay.ts @@ -30,10 +30,47 @@ export const relayBasic = async () => { evmRelayer.setRelayer(RelayerType.Agoric, axelarRelayer); - while (true) { + const expected = { + wallet: "0xd8E896691A0FCE4641D44d9E461A6d746A5c91dB", + nonce: 1, + sourceChain: "agoric", + }; + + const timeoutMs = 4 * 60 * 1000; // 4 minutes + const pollInterval = 1000; // 1 second + const startTime = Date.now(); + let found = false; + + while (Date.now() - startTime < timeoutMs) { await relay({ agoric: axelarRelayer, evm: evmRelayer, }); + + const logs = await factoryContract.queryFilter("NewWalletCreated"); + const match = logs.find((log) => { + if (!log.args) return false; + const [wallet, nonce, _, sourceChain] = log.args; + return ( + parseInt(nonce.toHexString(), 16) === expected.nonce && + wallet === expected.wallet && + sourceChain === expected.sourceChain + ); + }); + + if (match) { + console.log("✅ Matching NewWalletCreated event found:"); + console.log(JSON.stringify(match, null, 2)); + found = true; + process.exit(0); + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + if (!found) { + throw new Error( + "❌ Timed out: Expected NewWalletCreated event was not found within 4 minutes.", + ); } };