Skip to content

Implement requestRelay in Axelar gateway #200

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
9 changes: 8 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,13 @@ 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;
}

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)) {}
}
84 changes: 68 additions & 16 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 @@ -15,13 +17,21 @@ import {AxelarGatewayBase} from "./AxelarGatewayBase.sol";
*/
abstract contract AxelarGatewaySource is IERC7786GatewaySource, AxelarGatewayBase {
using InteroperableAddress for bytes;
using Strings for address;

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

uint256 private _lastSendId;
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 +40,71 @@ 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(++_lastSendId);
}
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, ""));
// TODO: How should we "stringify" addresses on non-evm chains. Axelar doesn't yet support hex format for all
// non evm addresses. Do we want to use Hex? Base58? Base64?
string memory axelarTarget = chainType == 0x0000
? Strings.toChecksumHexString(address(bytes20(remoteGateway)))
: Strings.toHexString(remoteGateway);

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

_axelarGateway.callContract(axelarDestination, axelarTarget, adapterPayload);
}

/**
* @dev Request relaying of a message initiated using `sendMessage`.
*
* NOTE: AxelarGasService does NOT take a gasLimit. Instead it uses the msg.value sent to determine the gas limit.
* This function ignores the provided `gasLimit` parameter.
*/
function requestRelay(bytes32 sendId, uint256 /*gasLimit*/, address refundRecipient) external payable {
MessageDetails memory details = _details[sendId];
require(details.payload.length > 0);

// delete storage for some refund
delete _details[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
);
}
}
34 changes: 34 additions & 0 deletions contracts/crosschain/utils/ERC7786Attributes.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.27;

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

/// @dev Library of helper to parse/process ERC-7786 attributes
library ERC7786Attributes {
/// @dev Parse the `requestRelay(uint256,uint256,address)` (0x4cbb573a) attribute into its components.
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)))
}
}

/// @dev Calldata variant of {tryDecodeRequestRelay}.
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 ERCs.
*/
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
Loading