Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion contracts/crosschain/axelar/AxelarGatewayBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pragma solidity ^0.8.27;

import {IAxelarGateway} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGateway.sol";
import {IAxelarGasService} from "@axelar-network/axelar-gmp-sdk-solidity/contracts/interfaces/IAxelarGasService.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol";

Expand All @@ -18,6 +19,7 @@ abstract contract AxelarGatewayBase is Ownable {

/// @dev Axelar's official gateway for the current chain.
IAxelarGateway internal immutable _axelarGateway;
IAxelarGasService internal immutable _axelarGasService;

// Remote gateway.
// `addr` is the isolated address part of ERC-7930. Its not a full ERC-7930 interoperable address.
Expand All @@ -41,8 +43,19 @@ abstract contract AxelarGatewayBase is Ownable {
error RemoteGatewayAlreadyRegistered(bytes2 chainType, bytes chainReference);

/// @dev Sets the local gateway address (i.e. Axelar's official gateway for the current chain).
constructor(IAxelarGateway _gateway) {
constructor(IAxelarGateway _gateway, IAxelarGasService _gasService) {
_axelarGateway = _gateway;
_axelarGasService = _gasService;
}

// This is already exposed by AxelarExecutable which AxelarDestinationGateway inherit from. Because its not
// virtual, resolution is not possible. Therefore, we should not expose it.
// function gateway() public view virtual returns (IAxelarGateway) {
// return _axelarGateway;
// }

function gasService() public view virtual returns (IAxelarGasService) {
return _axelarGasService;
}

/// @dev Returns the equivalent chain given an id that can be either either a binary interoperable address or an Axelar network identifier.
Expand Down
5 changes: 3 additions & 2 deletions contracts/crosschain/axelar/AxelarGatewayDuplex.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
pragma solidity ^0.8.27;

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {AxelarGatewayBase, IAxelarGateway} from "./AxelarGatewayBase.sol";
import {AxelarGatewayBase, IAxelarGateway, IAxelarGasService} from "./AxelarGatewayBase.sol";
import {AxelarGatewayDestination, AxelarExecutable} from "./AxelarGatewayDestination.sol";
import {AxelarGatewaySource} from "./AxelarGatewaySource.sol";

Expand All @@ -16,6 +16,7 @@ contract AxelarGatewayDuplex is AxelarGatewaySource, AxelarGatewayDestination {
/// @dev Initializes the contract with the Axelar gateway and the initial owner.
constructor(
IAxelarGateway gateway,
IAxelarGasService gasService,
address initialOwner
) Ownable(initialOwner) AxelarGatewayBase(gateway) AxelarExecutable(address(gateway)) {}
) Ownable(initialOwner) AxelarGatewayBase(gateway, gasService) AxelarExecutable(address(gateway)) {}
}
70 changes: 55 additions & 15 deletions contracts/crosschain/axelar/AxelarGatewaySource.sol
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should document somewhere that the gasLimit parameter is ignored, that instead msg.value determines the gas limit.

Does this behavior make sense?

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ pragma solidity ^0.8.27;
import {InteroperableAddress} from "@openzeppelin/contracts/utils/draft-InteroperableAddress.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
import {IERC7786GatewaySource} from "../../interfaces/IERC7786.sol";
import {IERC7786Attributes} from "../../interfaces/IERC7786Attributes.sol";
import {ERC7786Attributes} from "../utils/ERC7786Attributes.sol";
import {AxelarGatewayBase} from "./AxelarGatewayBase.sol";

/**
Expand All @@ -17,11 +19,20 @@ abstract contract AxelarGatewaySource is IERC7786GatewaySource, AxelarGatewayBas
using InteroperableAddress for bytes;
using Strings for address;

struct MessageDetails {
string destination;
string target;
bytes payload;
}

uint256 private _sendId;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename to _nextSendId?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its actually the _lastSendId (its incremented before use, otherwise the first time we use it we would get 0)

mapping(bytes32 => MessageDetails) private _details;

error UnsupportedNativeTransfer();

/// @inheritdoc IERC7786GatewaySource
function supportsAttribute(bytes4 /*selector*/) public pure returns (bool) {
return false;
function supportsAttribute(bytes4 selector) public pure returns (bool) {
return selector == IERC7786Attributes.requestRelay.selector;
}

/// @inheritdoc IERC7786GatewaySource
Expand All @@ -30,29 +41,58 @@ abstract contract AxelarGatewaySource is IERC7786GatewaySource, AxelarGatewayBas
bytes calldata payload,
bytes[] calldata attributes
) external payable returns (bytes32 sendId) {
require(msg.value == 0, UnsupportedNativeTransfer());
// Use of `if () revert` syntax to avoid accessing attributes[0] if it's empty
if (attributes.length > 0)
revert UnsupportedAttribute(attributes[0].length < 0x04 ? bytes4(0) : bytes4(attributes[0][0:4]));
// Process attributes (relay)
bool withRelay = false;
uint256 value = 0;
address refundRecipient = address(0);

for (uint256 i = 0; i < attributes.length; ++i) {
(withRelay, value, , refundRecipient) = ERC7786Attributes.tryDecodeRequestRelayCalldata(attributes[i]);
require(withRelay, UnsupportedAttribute(attributes[i].length < 0x04 ? bytes4(0) : bytes4(attributes[i])));
}
if (!withRelay) {
sendId = bytes32(++_sendId);
}
require(msg.value == value, UnsupportedNativeTransfer());

// Create the package
bytes memory sender = InteroperableAddress.formatEvmV1(block.chainid, msg.sender);
bytes memory adapterPayload = abi.encode(sender, recipient, payload);

// Emit event
sendId = bytes32(0); // Explicitly set to 0
// Emit event early (stack too deep)
emit MessageSent(sendId, sender, recipient, payload, 0, attributes);

// Send the message
(bytes2 chainType, bytes calldata chainReference, ) = recipient.parseV1Calldata();
string memory axelarDestination = getAxelarChain(InteroperableAddress.formatV1(chainType, chainReference, ""));
bytes memory remoteGateway = getRemoteGateway(chainType, chainReference);
_axelarGateway.callContract(
axelarDestination,
address(bytes20(remoteGateway)).toChecksumHexString(), // TODO non-evm chains?
adapterPayload
);
string memory axelarDestination = getAxelarChain(InteroperableAddress.formatV1(chainType, chainReference, ""));
string memory axelarTarget = address(bytes20(remoteGateway)).toChecksumHexString(); // TODO non-evm chains?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we need to resolve this TODO?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did update this line, with a clarification as to what is requiered on the axelar side.


_axelarGateway.callContract(axelarDestination, axelarTarget, adapterPayload);

if (withRelay) {
_axelarGasService.payNativeGasForContractCall{value: msg.value}(
address(this),
axelarDestination,
axelarTarget,
adapterPayload,
refundRecipient
);
} else {
_details[sendId] = MessageDetails(axelarDestination, axelarTarget, adapterPayload);
}
}

function requestRelay(bytes32 sendId, uint256 /*gasLimit*/, address refundRecipient) external payable {
MessageDetails storage details = _details[sendId];
require(details.payload.length > 0);

return sendId;
_axelarGasService.payNativeGasForContractCall{value: msg.value}(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it okay to call this function after callContract has been invoked? I have a feeling that it needs to be called beforehand...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator Author

@Amxx Amxx Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cjcobb23 what is the interface to pay gas AFTER a message is sent ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I know it's the addGas functions and those take tx hash and log index!

It really seems like there's no way to implement requestRelay for this adapter other than delaying callContract until requestRelay is called...

address(this),
details.destination,
details.target,
details.payload,
refundRecipient
);
}
}
31 changes: 31 additions & 0 deletions contracts/crosschain/utils/ERC7786Attributes.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

import {IERC7786Attributes} from "../../interfaces/IERC7786Attributes.sol";

library ERC7786Attributes {
function tryDecodeRequestRelay(
bytes memory attribute
) internal pure returns (bool success, uint256 value, uint256 gasLimit, address refundRecipient) {
success = bytes4(attribute) == IERC7786Attributes.requestRelay.selector && attribute.length >= 0x64;

assembly ("memory-safe") {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this technically unsafe as we're potentially reading out of bounds? Although we are multiplying by 0 in that case... so I think this is probably ok.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, multiply by zero is here for security. Any "unsafe" access should be nullified. If the content being read is irrelevant, I don't see how that could be a problem.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would definitely be a problem with traditional undefined behavior, but I don't think that's what happens with unsafe memory accesses in solc...

value := mul(success, mload(add(attribute, 0x24)))
gasLimit := mul(success, mload(add(attribute, 0x44)))
refundRecipient := mul(success, mload(add(attribute, 0x64)))
}
}

function tryDecodeRequestRelayCalldata(
bytes calldata attribute
) internal pure returns (bool success, uint256 value, uint256 gasLimit, address refundRecipient) {
success = bytes4(attribute) == IERC7786Attributes.requestRelay.selector && attribute.length >= 0x64;

assembly ("memory-safe") {
value := mul(success, calldataload(add(attribute.offset, 0x04)))
gasLimit := mul(success, calldataload(add(attribute.offset, 0x24)))
refundRecipient := mul(success, calldataload(add(attribute.offset, 0x44)))
}
}
}
10 changes: 10 additions & 0 deletions contracts/interfaces/IERC7786Attributes.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

/**
* @dev Standard attributes for ERC-7786. These attributes may be standardized in different ERC.
*/
interface IERC7786Attributes {
function requestRelay(uint256 value, uint256 gasLimit, address refundRecipient) external;
}
31 changes: 31 additions & 0 deletions contracts/mocks/crosschain/axelar/AxelarGasServiceMock.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

contract AxelarGasServiceMock {
event NativeGasPaidForContractCall(
address indexed sourceAddress,
string destinationChain,
string destinationAddress,
bytes32 indexed payloadHash,
uint256 gasFeeAmount,
address refundAddress
);

function payNativeGasForContractCall(
address sender,
string calldata destinationChain,
string calldata destinationAddress,
bytes calldata payload,
address refundAddress
) external payable {
emit NativeGasPaidForContractCall(
sender,
destinationChain,
destinationAddress,
keccak256(payload),
msg.value,
refundAddress
);
}
}

This file was deleted.

11 changes: 0 additions & 11 deletions contracts/mocks/docs/crosschain/MyCustomAxelarGatewayDuplex.sol

This file was deleted.

13 changes: 0 additions & 13 deletions contracts/mocks/docs/crosschain/MyCustomAxelarGatewaySource.sol

This file was deleted.

4 changes: 2 additions & 2 deletions test/crosschain/ERC7786OpenBridge.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ describe('ERC7786OpenBridge', function () {
await expect(txPromise)
.to.emit(this.bridgeA, 'MessageSent')
.withArgs(
ethers.ZeroHash,
anyValue,
this.chain.toErc7930(this.sender),
this.chain.toErc7930(this.destination),
this.payload,
Expand All @@ -140,7 +140,7 @@ describe('ERC7786OpenBridge', function () {
await expect(txPromise)
.to.emit(gatewayA, 'MessageSent')
.withArgs(
ethers.ZeroHash,
anyValue,
this.chain.toErc7930(this.bridgeA),
this.chain.toErc7930(this.bridgeB),
anyValue,
Expand Down
53 changes: 46 additions & 7 deletions test/crosschain/axelar/AxelarGateway.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@ const { expect } = require('chai');
const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
const { anyValue } = require('@nomicfoundation/hardhat-chai-matchers/withArgs');

const ERC7786Attributes = require('../../helpers/erc7786attributes');

const AxelarHelper = require('./AxelarHelper');

async function fixture() {
const [owner, sender, ...accounts] = await ethers.getSigners();
const [owner, sender, refundRecipient, ...accounts] = await ethers.getSigners();

const { chain, axelar, gatewayA, gatewayB } = await AxelarHelper.deploy(owner);

const receiver = await ethers.deployContract('$ERC7786ReceiverMock', [gatewayB]);
const invalidReceiver = await ethers.deployContract('$ERC7786ReceiverInvalidMock');

return { owner, sender, accounts, chain, axelar, gatewayA, gatewayB, receiver, invalidReceiver };
return { owner, sender, refundRecipient, accounts, chain, axelar, gatewayA, gatewayB, receiver, invalidReceiver };
}

describe('AxelarGateway', function () {
Expand All @@ -22,14 +24,16 @@ describe('AxelarGateway', function () {
});

it('initial setup', async function () {
await expect(this.gatewayA.gateway()).to.eventually.equal(this.axelar);
await expect(this.gatewayA.gateway()).to.eventually.equal(this.axelar.gateway);
await expect(this.gatewayA.gasService()).to.eventually.equal(this.axelar.gasService);
await expect(this.gatewayA.getAxelarChain(this.chain.erc7930)).to.eventually.equal('local');
await expect(this.gatewayA.getErc7930Chain('local')).to.eventually.equal(this.chain.erc7930);
await expect(this.gatewayA.getRemoteGateway(this.chain.erc7930)).to.eventually.equal(
this.gatewayB.target.toLowerCase(),
);

await expect(this.gatewayB.gateway()).to.eventually.equal(this.axelar);
await expect(this.gatewayB.gateway()).to.eventually.equal(this.axelar.gateway);
await expect(this.gatewayB.gasService()).to.eventually.equal(this.axelar.gasService);
await expect(this.gatewayB.getAxelarChain(this.chain.erc7930)).to.eventually.equal('local');
await expect(this.gatewayB.getErc7930Chain('local')).to.eventually.equal(this.chain.erc7930);
await expect(this.gatewayB.getRemoteGateway(this.chain.erc7930)).to.eventually.equal(
Expand All @@ -47,13 +51,48 @@ describe('AxelarGateway', function () {
[erc7930Sender, erc7930Recipient, payload],
);

const sendId = '0x0000000000000000000000000000000000000000000000000000000000000001';

await expect(this.gatewayA.connect(this.sender).sendMessage(erc7930Recipient, payload, attributes))
.to.emit(this.gatewayA, 'MessageSent')
.withArgs(ethers.ZeroHash, erc7930Sender, erc7930Recipient, payload, 0n, attributes)
.to.emit(this.axelar, 'ContractCall')
.withArgs(sendId, erc7930Sender, erc7930Recipient, payload, 0n, attributes)
.to.emit(this.axelar.gateway, 'ContractCall')
.withArgs(this.gatewayA, 'local', this.gatewayB, ethers.keccak256(encoded), encoded)
.to.emit(this.axelar.gateway, 'MessageExecuted')
.withArgs(anyValue)
.to.emit(this.receiver, 'MessageReceived')
.withArgs(this.gatewayB, anyValue, erc7930Sender, payload);

await expect(this.gatewayA.connect(this.sender).requestRelay(sendId, 0n, this.refundRecipient, { value: 1000n }))
.to.emit(this.axelar.gasService, 'NativeGasPaidForContractCall')
.withArgs(this.gatewayA, 'local', this.gatewayB, ethers.keccak256(encoded), 1000n, this.refundRecipient);
});

it('workflow (with requestRelay attribute)', async function () {
const erc7930Sender = this.chain.toErc7930(this.sender);
const erc7930Recipient = this.chain.toErc7930(this.receiver);
const payload = ethers.randomBytes(128);
const attributes = [
ERC7786Attributes.encodeFunctionData('requestRelay', [1000n, 0n, this.refundRecipient.address]),
];
const encoded = ethers.AbiCoder.defaultAbiCoder().encode(
['bytes', 'bytes', 'bytes'],
[erc7930Sender, erc7930Recipient, payload],
);

const sendId = '0x0000000000000000000000000000000000000000000000000000000000000000';

await expect(
this.gatewayA.connect(this.sender).sendMessage(erc7930Recipient, payload, attributes, { value: 1000n }),
)
.to.emit(this.gatewayA, 'MessageSent')
.withArgs(sendId, erc7930Sender, erc7930Recipient, payload, 0n, attributes)
.to.emit(this.axelar.gateway, 'ContractCall')
.withArgs(this.gatewayA, 'local', this.gatewayB, ethers.keccak256(encoded), encoded)
.to.emit(this.axelar, 'MessageExecuted')
.to.emit(this.axelar.gateway, 'MessageExecuted')
.withArgs(anyValue)
.to.emit(this.axelar.gasService, 'NativeGasPaidForContractCall')
.withArgs(this.gatewayA, 'local', this.gatewayB, ethers.keccak256(encoded), 1000n, this.refundRecipient)
.to.emit(this.receiver, 'MessageReceived')
.withArgs(this.gatewayB, anyValue, erc7930Sender, payload);
});
Expand Down
10 changes: 7 additions & 3 deletions test/crosschain/axelar/AxelarHelper.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ const { getLocalChain } = require('@openzeppelin/contracts/test/helpers/chains')
async function deploy(owner) {
const chain = await getLocalChain();

const axelar = await ethers.deployContract('AxelarGatewayMock');
const gatewayA = await ethers.deployContract('AxelarGatewayDuplex', [axelar, owner]);
const gatewayB = await ethers.deployContract('AxelarGatewayDuplex', [axelar, owner]);
const axelar = await Promise.all([
ethers.deployContract('AxelarGatewayMock'),
ethers.deployContract('AxelarGasServiceMock'),
]).then(([gateway, gasService]) => ({ gateway, gasService }));

const gatewayA = await ethers.deployContract('AxelarGatewayDuplex', [axelar.gateway, axelar.gasService, owner]);
const gatewayB = await ethers.deployContract('AxelarGatewayDuplex', [axelar.gateway, axelar.gasService, owner]);

await Promise.all([
gatewayA.connect(owner).registerChainEquivalence(chain.erc7930, 'local'),
Expand Down
Loading