diff --git a/.github/workflows/agoric-integration.yml b/.github/workflows/agoric-integration.yml index e737abc1..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 + run: npm run relay:cosmos diff --git a/integration/scripts/make-account.mjs b/integration/scripts/make-account.mjs index 5e5d48e6..5ef90e2e 100755 --- a/integration/scripts/make-account.mjs +++ b/integration/scripts/make-account.mjs @@ -1,105 +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", - offerArgs: { - gasAmount: 0n, - }, - }); - - 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/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/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts b/packages/axelar-local-dev-cosmos/src/__tests__/Factory.spec.ts index b9f313f0..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; @@ -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,12 +128,13 @@ 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({ commandId, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: factory.target, payload: payloadHash, @@ -145,15 +145,64 @@ describe("Factory", () => { const tx = await factory.execute( commandId, - sourceContract, + sourceChain, sourceAddress, payload, ); 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 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 () => { @@ -188,7 +237,7 @@ describe("Factory", () => { const commandId1 = getCommandId(); await approveMessage({ commandId: commandId1, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: wallet.target, payload: payloadHash, @@ -199,7 +248,7 @@ describe("Factory", () => { const execTx = await wallet.execute( commandId1, - sourceContract, + sourceChain, sourceAddress, multicallPayload, ); @@ -222,7 +271,7 @@ describe("Factory", () => { const commandId2 = getCommandId(); await approveMessageWithToken({ commandId: commandId2, - from: sourceContract, + from: sourceChain, sourceAddress, targetAddress: wallet.target, payload: payloadHash2, @@ -235,7 +284,7 @@ describe("Factory", () => { const execWithTokenTx = await wallet.executeWithToken( commandId2, - sourceContract, + sourceChain, sourceAddress, multicallPayload2, "USDC", 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"); } } diff --git a/packages/axelar-local-dev-cosmos/src/relay.ts b/packages/axelar-local-dev-cosmos/src/relay.ts index beeb69bb..4ffe673e 100644 --- a/packages/axelar-local-dev-cosmos/src/relay.ts +++ b/packages/axelar-local-dev-cosmos/src/relay.ts @@ -24,20 +24,53 @@ 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); 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.", + ); } };