Browse Source

ERC20Bridgable (ERC-7802) (#5735)

Co-authored-by: ernestognw <ernestognw@gmail.com>
Co-authored-by: Arr00 <13561405+arr00@users.noreply.github.com>
Hadrien Croubois 3 months ago
parent
commit
5c79432e40

+ 5 - 0
.changeset/ripe-bears-hide.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': minor
+---
+
+`ERC20Bridgeable`: Implementation of ERC-7802 that makes an ERC-20 compatible with crosschain bridges.

+ 3 - 0
contracts/interfaces/README.adoc

@@ -45,6 +45,7 @@ are useful to interact with third party contracts that implement them.
 - {IERC6909Metadata}
 - {IERC6909TokenSupply}
 - {IERC7674}
+- {IERC7802}
 
 == Detailed ABI
 
@@ -97,3 +98,5 @@ are useful to interact with third party contracts that implement them.
 {{IERC6909TokenSupply}}
 
 {{IERC7674}}
+
+{{IERC7802}}

+ 30 - 0
contracts/interfaces/draft-IERC7802.sol

@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.6.2;
+
+import {IERC165} from "./IERC165.sol";
+
+/// @title IERC7802
+/// @notice Defines the interface for crosschain ERC20 transfers.
+interface IERC7802 is IERC165 {
+    /// @notice Emitted when a crosschain transfer mints tokens.
+    /// @param to       Address of the account tokens are being minted for.
+    /// @param amount   Amount of tokens minted.
+    /// @param sender   Address of the caller (msg.sender) who invoked crosschainMint.
+    event CrosschainMint(address indexed to, uint256 amount, address indexed sender);
+
+    /// @notice Emitted when a crosschain transfer burns tokens.
+    /// @param from     Address of the account tokens are being burned from.
+    /// @param amount   Amount of tokens burned.
+    /// @param sender   Address of the caller (msg.sender) who invoked crosschainBurn.
+    event CrosschainBurn(address indexed from, uint256 amount, address indexed sender);
+
+    /// @notice Mint tokens through a crosschain transfer.
+    /// @param _to     Address to mint tokens to.
+    /// @param _amount Amount of tokens to mint.
+    function crosschainMint(address _to, uint256 _amount) external;
+
+    /// @notice Burn tokens through a crosschain transfer.
+    /// @param _from   Address to burn tokens from.
+    /// @param _amount Amount of tokens to burn.
+    function crosschainBurn(address _from, uint256 _amount) external;
+}

+ 26 - 0
contracts/mocks/token/ERC20BridgeableMock.sol

@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {ERC20, ERC20Bridgeable} from "../../token/ERC20/extensions/draft-ERC20Bridgeable.sol";
+
+abstract contract ERC20BridgeableMock is ERC20Bridgeable {
+    address private _bridge;
+
+    error OnlyTokenBridge();
+    event OnlyTokenBridgeFnCalled(address caller);
+
+    constructor(address bridge) {
+        _bridge = bridge;
+    }
+
+    function onlyTokenBridgeFn() external onlyTokenBridge {
+        emit OnlyTokenBridgeFnCalled(msg.sender);
+    }
+
+    function _checkTokenBridge(address sender) internal view override {
+        if (sender != _bridge) {
+            revert OnlyTokenBridge();
+        }
+    }
+}

+ 3 - 0
contracts/token/ERC20/README.adoc

@@ -16,6 +16,7 @@ There are a few core contracts that implement the behavior specified in the ERC-
 Additionally there are multiple custom extensions, including:
 
 * {ERC20Permit}: gasless approval of tokens (standardized as ERC-2612).
+* {ERC20Bridgeable}: compatibility with crosschain bridges through ERC-7802.
 * {ERC20Burnable}: destruction of own tokens.
 * {ERC20Capped}: enforcement of a cap to the total supply when minting tokens.
 * {ERC20Pausable}: ability to pause token transfers.
@@ -50,6 +51,8 @@ NOTE: This core set of contracts is designed to be unopinionated, allowing devel
 
 {{ERC20Permit}}
 
+{{ERC20Bridgeable}}
+
 {{ERC20Burnable}}
 
 {{ERC20Capped}}

+ 50 - 0
contracts/token/ERC20/extensions/draft-ERC20Bridgeable.sol

@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.20;
+
+import {ERC20} from "../ERC20.sol";
+import {ERC165, IERC165} from "../../../utils/introspection/ERC165.sol";
+import {IERC7802} from "../../../interfaces/draft-IERC7802.sol";
+
+/**
+ * @dev ERC20 extension that implements the standard token interface according to
+ * https://eips.ethereum.org/EIPS/eip-7802[ERC-7802].
+ */
+abstract contract ERC20Bridgeable is ERC20, ERC165, IERC7802 {
+    /// @dev Modifier to restrict access to the token bridge.
+    modifier onlyTokenBridge() {
+        // Token bridge should never be impersonated using a relayer/forwarder. Using msg.sender is preferable to
+        // _msgSender() for security reasons.
+        _checkTokenBridge(msg.sender);
+        _;
+    }
+
+    /// @inheritdoc ERC165
+    function supportsInterface(bytes4 interfaceId) public view virtual override(ERC165, IERC165) returns (bool) {
+        return interfaceId == type(IERC7802).interfaceId || super.supportsInterface(interfaceId);
+    }
+
+    /**
+     * @dev See {IERC7802-crosschainMint}. Emits a {IERC7802-CrosschainMint} event.
+     */
+    function crosschainMint(address to, uint256 value) public virtual override onlyTokenBridge {
+        _mint(to, value);
+        emit CrosschainMint(to, value, _msgSender());
+    }
+
+    /**
+     * @dev See {IERC7802-crosschainBurn}. Emits a {IERC7802-CrosschainBurn} event.
+     */
+    function crosschainBurn(address from, uint256 value) public virtual override onlyTokenBridge {
+        _burn(from, value);
+        emit CrosschainBurn(from, value, _msgSender());
+    }
+
+    /**
+     * @dev Checks if the caller is a trusted token bridge. MUST revert otherwise.
+     *
+     * Developers should implement this function using an access control mechanism that allows
+     * customizing the list of allowed senders. Consider using {AccessControl} or {AccessManaged}.
+     */
+    function _checkTokenBridge(address caller) internal virtual;
+}

+ 89 - 0
test/token/ERC20/extensions/draft-ERC20Bridgeable.test.js

@@ -0,0 +1,89 @@
+const { ethers } = require('hardhat');
+const { expect } = require('chai');
+const { loadFixture } = require('@nomicfoundation/hardhat-network-helpers');
+
+const { shouldBehaveLikeERC20 } = require('../ERC20.behavior.js');
+const { shouldSupportInterfaces } = require('../../../utils/introspection/SupportsInterface.behavior');
+
+const name = 'My Token';
+const symbol = 'MTKN';
+const initialSupply = 100n;
+
+async function fixture() {
+  const [other, bridge, ...accounts] = await ethers.getSigners();
+
+  const token = await ethers.deployContract('$ERC20BridgeableMock', [name, symbol, bridge]);
+  await token.$_mint(accounts[0], initialSupply);
+
+  return { bridge, other, accounts, token };
+}
+
+describe('ERC20Bridgeable', function () {
+  beforeEach(async function () {
+    Object.assign(this, await loadFixture(fixture));
+  });
+
+  describe('onlyTokenBridgeFn', function () {
+    it('reverts when called by non-bridge', async function () {
+      await expect(this.token.onlyTokenBridgeFn()).to.be.revertedWithCustomError(this.token, 'OnlyTokenBridge');
+    });
+
+    it('does not revert when called by bridge', async function () {
+      await expect(this.token.connect(this.bridge).onlyTokenBridgeFn())
+        .to.emit(this.token, 'OnlyTokenBridgeFnCalled')
+        .withArgs(this.bridge);
+    });
+  });
+
+  describe('crosschainMint', function () {
+    it('reverts when called by non-bridge', async function () {
+      await expect(this.token.crosschainMint(this.other, 100n)).to.be.revertedWithCustomError(
+        this.token,
+        'OnlyTokenBridge',
+      );
+    });
+
+    it('mints amount provided by the bridge when calling crosschainMint', async function () {
+      const amount = 100n;
+      await expect(this.token.connect(this.bridge).crosschainMint(this.other, amount))
+        .to.emit(this.token, 'CrosschainMint')
+        .withArgs(this.other, amount, this.bridge)
+        .to.emit(this.token, 'Transfer')
+        .withArgs(ethers.ZeroAddress, this.other, amount);
+
+      await expect(this.token.balanceOf(this.other)).to.eventually.equal(amount);
+    });
+  });
+
+  describe('crosschainBurn', function () {
+    it('reverts when called by non-bridge', async function () {
+      await expect(this.token.crosschainBurn(this.other, 100n)).to.be.revertedWithCustomError(
+        this.token,
+        'OnlyTokenBridge',
+      );
+    });
+
+    it('burns amount provided by the bridge when calling crosschainBurn', async function () {
+      const amount = 100n;
+      await this.token.$_mint(this.other, amount);
+
+      await expect(this.token.connect(this.bridge).crosschainBurn(this.other, amount))
+        .to.emit(this.token, 'CrosschainBurn')
+        .withArgs(this.other, amount, this.bridge)
+        .to.emit(this.token, 'Transfer')
+        .withArgs(this.other, ethers.ZeroAddress, amount);
+
+      await expect(this.token.balanceOf(this.other)).to.eventually.equal(0);
+    });
+  });
+
+  describe('ERC165', function () {
+    shouldSupportInterfaces({
+      ERC7802: ['crosschainMint(address,uint256)', 'crosschainBurn(address,uint256)'],
+    });
+  });
+
+  describe('ERC20 behavior', function () {
+    shouldBehaveLikeERC20(initialSupply);
+  });
+});