diff --git a/.changeset/tricky-avocados-dream.md b/.changeset/tricky-avocados-dream.md new file mode 100644 index 00000000..84cb9198 --- /dev/null +++ b/.changeset/tricky-avocados-dream.md @@ -0,0 +1,6 @@ +--- +'@fuel-bridge/solidity-contracts': minor +'@fuel-bridge/test-utils': minor +--- + +add support for permit tokens in the erc20 gateway diff --git a/packages/integration-tests/tests/bridge_erc20.ts b/packages/integration-tests/tests/bridge_erc20.ts index c82850a0..ee0d7096 100644 --- a/packages/integration-tests/tests/bridge_erc20.ts +++ b/packages/integration-tests/tests/bridge_erc20.ts @@ -3,7 +3,10 @@ import { RATE_LIMIT_AMOUNT, RATE_LIMIT_DURATION, } from '@fuel-bridge/solidity-contracts/protocol/constants'; -import type { Token } from '@fuel-bridge/solidity-contracts/typechain'; +import type { + Token, + MockPermitToken, +} from '@fuel-bridge/solidity-contracts/typechain'; import type { TestEnvironment } from '@fuel-bridge/test-utils'; import { setupEnvironment, @@ -11,6 +14,7 @@ import { waitForMessage, createRelayMessageParams, getOrDeployECR20Contract, + getOrDeployERC20PermitContract, getOrDeployL2Bridge, FUEL_TX_PARAMS, getMessageOutReceipt, @@ -42,12 +46,15 @@ describe('Bridging ERC20 tokens', async function () { let env: TestEnvironment; let eth_testToken: Token; + let eth_permitTestToken: MockPermitToken; let eth_testTokenAddress: string; + let eth_permitTestTokenAddress: string; let eth_erc20GatewayAddress: string; let fuel_bridge: BridgeFungibleToken; let fuel_bridgeImpl: BridgeFungibleToken; let fuel_bridgeContractId: string; let fuel_testAssetId: string; + let fuel_test_permit_token_AssetId: string; // override the default test timeout from 2000ms this.timeout(DEFAULT_TIMEOUT_MS); @@ -114,7 +121,7 @@ describe('Bridging ERC20 tokens', async function () { ); } - async function relayMessage( + async function relayMessageFromFuel( env: TestEnvironment, withdrawMessageProof: MessageProof ) { @@ -134,6 +141,96 @@ describe('Bridging ERC20 tokens', async function () { ); } + async function relayMessageFromEthereum( + env: TestEnvironment, + fuelTokenMessageReceiver: AbstractAddress, + fuelTokenMessageNonce: BN, + fuel_AssetId: string, + amount: bigint + ) { + // relay the message ourselves + const message = await waitForMessage( + env.fuel.provider, + fuelTokenMessageReceiver, + fuelTokenMessageNonce, + FUEL_MESSAGE_TIMEOUT_MS + ); + expect(message).to.not.be.null; + + const tx = await relayCommonMessage(env.fuel.deployer, message, { + maturity: undefined, + contractIds: [fuel_bridgeImpl.id.toHexString()], + }); + + const txResult = await tx.waitForResult(); + + expect(txResult.status).to.equal('success'); + expect(txResult.mintedAssets.length).to.equal(1); + + const [mintedAsset] = txResult.mintedAssets; + + expect(mintedAsset.assetId).to.equal(fuel_AssetId); + expect(mintedAsset.amount.toString()).to.equal( + (amount / DECIMAL_DIFF).toString() + ); + } + + async function buildPermitParams( + name: string, + tokenAddress: string, + gatewayAddress: string, + amount: bigint, + nonce: bigint, + deadline: number, + deployer: Signer + ) { + const domain: any = { + name: name, + version: '1', + chainId: env.eth.provider._network.chainId, + verifyingContract: tokenAddress, + }; + + const types: any = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const values: any = { + owner: await deployer.getAddress(), + spender: gatewayAddress, + value: amount.toString(), + nonce: nonce.toString(), + deadline: deadline.toString(), + }; + + return { domain, types, values }; + } + + function parseSignature(signature: string) { + signature = signature.startsWith('0x') ? signature.slice(2) : signature; + + // Ensure the signature length is correct + if (signature.length !== 130) { + throw new Error('Invalid signature length!'); + } + // Extract R, S, V + const r = '0x' + signature.slice(0, 64); + const s = '0x' + signature.slice(64, 128); + const v = parseInt(signature.slice(128, 130), 16); + // Return formatted values + return { + r, + s, + v, + }; + } + before(async () => { env = await setupEnvironment({}); eth_erc20GatewayAddress = ( @@ -141,7 +238,11 @@ describe('Bridging ERC20 tokens', async function () { ).toLowerCase(); eth_testToken = await getOrDeployECR20Contract(env); + eth_permitTestToken = await getOrDeployERC20PermitContract(env); eth_testTokenAddress = (await eth_testToken.getAddress()).toLowerCase(); + eth_permitTestTokenAddress = ( + await eth_permitTestToken.getAddress() + ).toLowerCase(); const { contract, implementation } = await getOrDeployL2Bridge( env, @@ -156,6 +257,11 @@ describe('Bridging ERC20 tokens', async function () { await env.eth.fuelERC20Gateway.setAssetIssuerId(fuel_bridgeContractId); fuel_testAssetId = getTokenId(fuel_bridge, eth_testTokenAddress); + fuel_test_permit_token_AssetId = getTokenId( + fuel_bridge, + eth_permitTestTokenAddress + ); + // initializing rate limit params for the token await env.eth.fuelERC20Gateway .connect(env.eth.deployer) @@ -200,13 +306,17 @@ describe('Bridging ERC20 tokens', async function () { describe('Bridge ERC20 to Fuel', async () => { const NUM_TOKENS = 100000000000000000000n; + const DEADLINE = Math.floor(Date.now() / 1000) + 600; // 10 mins from current timestamp let ethereumTokenSender: Signer; let ethereumTokenSenderAddress: string; let ethereumTokenSenderBalance: bigint; + let ethereumPermitTokenSenderBalance: bigint; let fuelTokenReceiver: FuelWallet; let fuelTokenReceiverAddress: string; let fuelTokenReceiverBalance: BN; + let fuelPermitTokenReceiverBalance: BN; let fuelTokenMessageNonce: BN; + let fuelTokenMessageNonceForPermitToken: BN; let fuelTokenMessageReceiver: AbstractAddress; before(async () => { @@ -217,14 +327,88 @@ describe('Bridging ERC20 tokens', async function () { .mint(ethereumTokenSenderAddress, NUM_TOKENS) .then((tx) => tx.wait()); + await eth_permitTestToken + .mint(ethereumTokenSenderAddress, NUM_TOKENS) + .then((tx) => tx.wait()); + ethereumTokenSenderBalance = await eth_testToken.balanceOf( ethereumTokenSenderAddress ); + ethereumPermitTokenSenderBalance = await eth_permitTestToken.balanceOf( + ethereumTokenSenderAddress + ); fuelTokenReceiver = env.fuel.signers[0]; fuelTokenReceiverAddress = fuelTokenReceiver.address.toHexString(); fuelTokenReceiverBalance = await fuelTokenReceiver.getBalance( fuel_testAssetId ); + fuelPermitTokenReceiverBalance = await fuelTokenReceiver.getBalance( + fuel_test_permit_token_AssetId + ); + }); + + it('Bridge ERC20 token with permit via FuelERC20Gateway', async () => { + const tokenName = await eth_permitTestToken.name(); + const tokenAddress = await eth_permitTestToken.getAddress(); + const gatewayAddress = await env.eth.fuelERC20Gateway.getAddress(); + const deployerNonce = await eth_permitTestToken.nonces( + ethereumTokenSender + ); + + const signatureParams = await buildPermitParams( + tokenName, + tokenAddress, + gatewayAddress, + NUM_TOKENS, + deployerNonce, + DEADLINE, + ethereumTokenSender + ); + + const signature = await ethereumTokenSender.signTypedData( + signatureParams.domain, + signatureParams.types, + signatureParams.values + ); + + const { r, s, v } = parseSignature(signature); + const permitData = { + deadline: DEADLINE, + v, + r, + s, + }; + + // use the FuelERC20Gateway to deposit test tokens and receive equivalent tokens on Fuel + const receipt = await env.eth.fuelERC20Gateway + .connect(ethereumTokenSender) + .depositWithPermit( + fuelTokenReceiverAddress, + eth_permitTestTokenAddress, + NUM_TOKENS, + permitData + ) + .then((tx) => tx.wait()); + + expect(receipt.status).to.equal(1); + + // parse events from logs + const [event, ...restOfEvents] = + await env.eth.fuelMessagePortal.queryFilter( + env.eth.fuelMessagePortal.filters.MessageSent, + receipt.blockNumber, + receipt.blockNumber + ); + expect(restOfEvents.length).to.be.eq(0); // Should be only 1 event + + fuelTokenMessageNonceForPermitToken = new BN(event.args.nonce.toString()); + + // check that the sender balance has decreased by the expected amount + const newSenderBalance = await eth_permitTestToken.balanceOf( + ethereumTokenSenderAddress + ); + expect(newSenderBalance === ethereumPermitTokenSenderBalance - NUM_TOKENS) + .to.be.true; }); it('Bridge ERC20 via FuelERC20Gateway', async () => { @@ -241,7 +425,6 @@ describe('Bridging ERC20 tokens', async function () { .then((tx) => tx.wait()); expect(receipt.status).to.equal(1); - // parse events from logs const [event, ...restOfEvents] = await env.eth.fuelMessagePortal.queryFilter( @@ -262,34 +445,27 @@ describe('Bridging ERC20 tokens', async function () { .true; }); - it('Relay message from Ethereum on Fuel', async () => { + it('Relay messages from Ethereum on Fuel', async () => { // override the default test timeout from 2000ms this.timeout(FUEL_MESSAGE_TIMEOUT_MS); - - // relay the message ourselves - const message = await waitForMessage( - env.fuel.provider, + // relay the standard erc20 deposit + await relayMessageFromEthereum( + env, fuelTokenMessageReceiver, fuelTokenMessageNonce, - FUEL_MESSAGE_TIMEOUT_MS + fuel_testAssetId, + NUM_TOKENS ); - expect(message).to.not.be.null; - const tx = await relayCommonMessage(env.fuel.deployer, message, { - maturity: undefined, - contractIds: [fuel_bridgeImpl.id.toHexString()], - }); - - const txResult = await tx.waitForResult(); - - expect(txResult.status).to.equal('success'); - expect(txResult.mintedAssets.length).to.equal(1); - - const [mintedAsset] = txResult.mintedAssets; - - expect(mintedAsset.assetId).to.equal(fuel_testAssetId); - expect(mintedAsset.amount.toString()).to.equal( - (NUM_TOKENS / DECIMAL_DIFF).toString() + // override the default test timeout from 2000ms + this.timeout(FUEL_MESSAGE_TIMEOUT_MS); + // relay the erc20 permit token deposit + await relayMessageFromEthereum( + env, + fuelTokenMessageReceiver, + fuelTokenMessageNonceForPermitToken, + fuel_test_permit_token_AssetId, + NUM_TOKENS ); }); @@ -320,6 +496,19 @@ describe('Bridging ERC20 tokens', async function () { ).to.be.true; }); + it('Check ERC20 permit token arrived on Fuel', async () => { + // check that the recipient balance has increased by the expected amount + const newReceiverPermitBalance = await fuelTokenReceiver.getBalance( + fuel_test_permit_token_AssetId + ); + + expect( + newReceiverPermitBalance.eq( + fuelPermitTokenReceiverBalance.add(toBeHex(NUM_TOKENS / DECIMAL_DIFF)) + ) + ).to.be.true; + }); + it('Bridge metadata', async () => { // use the FuelERC20Gateway to deposit test tokens and receive equivalent tokens on Fuel const receipt = await env.eth.fuelERC20Gateway @@ -416,7 +605,7 @@ describe('Bridging ERC20 tokens', async function () { await env.eth.fuelERC20Gateway.currentPeriodEnd(eth_testTokenAddress); // relay message - await relayMessage(env, withdrawMessageProof); + await relayMessageFromFuel(env, withdrawMessageProof); // check rate limit params const withdrawnAmountAfterRelay = @@ -541,7 +730,7 @@ describe('Bridging ERC20 tokens', async function () { ); // relay message - await relayMessage(env, withdrawMessageProof); + await relayMessageFromFuel(env, withdrawMessageProof); const currentPeriodEndAfterRelay = await env.eth.fuelERC20Gateway.currentPeriodEnd(eth_testTokenAddress); diff --git a/packages/solidity-contracts/contracts/messaging/gateway/FuelERC20Gateway/FuelERC20GatewayV4.sol b/packages/solidity-contracts/contracts/messaging/gateway/FuelERC20Gateway/FuelERC20GatewayV4.sol index 5ace6e90..95be1e30 100644 --- a/packages/solidity-contracts/contracts/messaging/gateway/FuelERC20Gateway/FuelERC20GatewayV4.sol +++ b/packages/solidity-contracts/contracts/messaging/gateway/FuelERC20Gateway/FuelERC20GatewayV4.sol @@ -5,12 +5,14 @@ import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Ini import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; import {PausableUpgradeable} from "@openzeppelin/contracts-upgradeable/security/PausableUpgradeable.sol"; +import {IERC20PermitUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20PermitUpgradeable.sol"; import {IERC20MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; import {SafeERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; import {CommonPredicates} from "../../../lib/CommonPredicates.sol"; import {FuelMessagePortal} from "../../../fuelchain/FuelMessagePortal.sol"; import {FuelBridgeBase} from "../FuelBridgeBase/FuelBridgeBase.sol"; import {FuelMessagesEnabledUpgradeable} from "../../FuelMessagesEnabledUpgradeable.sol"; +import {PermitSignature} from "../../../utils/Permit.sol"; /// @title FuelERC20GatewayV4 /// @notice The L1 side of the general ERC20 gateway with Fuel. @@ -37,6 +39,7 @@ contract FuelERC20GatewayV4 is error InvalidAssetIssuerID(); error InvalidSender(); error InvalidAmount(); + error PermitNotAllowed(); error RateLimitExceeded(); /// @dev Emitted when tokens are deposited from Ethereum to Fuel @@ -222,7 +225,7 @@ contract FuelERC20GatewayV4 is /// @param tokenAddress Address of the token being transferred to Fuel /// @param amount Amount of tokens to deposit /// @dev Made payable to reduce gas costs - function deposit(bytes32 to, address tokenAddress, uint256 amount) external payable virtual whenNotPaused { + function deposit(bytes32 to, address tokenAddress, uint256 amount) public payable virtual whenNotPaused { if (assetIssuerId == bytes32(0)) revert InvalidAssetIssuerID(); uint8 decimals = _getTokenDecimals(tokenAddress); @@ -252,7 +255,7 @@ contract FuelERC20GatewayV4 is address tokenAddress, uint256 amount, bytes calldata data - ) external payable virtual whenNotPaused { + ) public payable virtual whenNotPaused { if (assetIssuerId == bytes32(0)) revert InvalidAssetIssuerID(); uint8 decimals = _getTokenDecimals(tokenAddress); @@ -322,6 +325,78 @@ contract FuelERC20GatewayV4 is emit Withdrawal(bytes32(uint256(uint160(to))), tokenAddress, amount); } + /// @notice Deposits the given tokens to an account on Fuel using permit signature for approval + /// @param to Fuel address to deposit tokens to + /// @param tokenAddress Address of the token being transferred to Fuel + /// @param amount Amount of tokens to deposit + /// @param permitSignature struct containing the permit signature + /// @dev Made payable to reduce gas costs + function depositWithPermit( + bytes32 to, + address tokenAddress, + uint256 amount, + PermitSignature memory permitSignature + ) public payable virtual whenNotPaused { + // If the deadline is zero, we revert as we assume the token does not have permit functionality so then `deposit` can be called directly. + if (permitSignature.deadline == 0) revert PermitNotAllowed(); + + // Adding a try-catch clause allows to skip dos transactions here + // We are not interested in either catching the error or implementing + // a success flow, we just continue and let the transfer revert in `deposit` + // sets token allowance with permit signature + try + IERC20PermitUpgradeable(tokenAddress).permit( + msg.sender, + address(this), + amount, + permitSignature.deadline, + permitSignature.v, + permitSignature.r, + permitSignature.s + ) + {} catch {} + + // for backward compatability with frontend, we call the existing `deposit` method + deposit(to, tokenAddress, amount); + } + + /// @notice Deposits the given tokens to an account on Fuel using permit signature for approval + /// @param to Fuel address to deposit tokens to + /// @param tokenAddress Address of the token being transferred to Fuel + /// @param amount Amount of tokens to deposit + /// @param data Optional data to send with the deposit + /// @param permitSignature struct containing the permit signature + /// @dev Made payable to reduce gas costs + function depositWithDataAndPermit( + bytes32 to, + address tokenAddress, + uint256 amount, + bytes calldata data, + PermitSignature memory permitSignature + ) public payable virtual whenNotPaused { + // If the deadline is zero, we revert as we assume the token does not have permit functionality so then `depositWithData` can be called directly. + if (permitSignature.deadline == 0) revert PermitNotAllowed(); + + // Adding a try-catch clause allows to skip dos transactions here + // We are not interested in either catching the error or implementing + // a success flow, we just continue and let the transfer revert in `depositWithData` + // sets token allowance with permit signature + try + IERC20PermitUpgradeable(tokenAddress).permit( + msg.sender, + address(this), + amount, + permitSignature.deadline, + permitSignature.v, + permitSignature.r, + permitSignature.s + ) + {} catch {} + + // for backward compatability with frontend, we call the existing `deposit` method + depositWithData(to, tokenAddress, amount, data); + } + /// @notice Deposits the given tokens to an account or contract on Fuel /// @param tokenAddress Address of the token being transferred to Fuel /// @param amount tokens that have been deposited diff --git a/packages/solidity-contracts/contracts/test/MockPermitToken.sol b/packages/solidity-contracts/contracts/test/MockPermitToken.sol new file mode 100644 index 00000000..d521f56a --- /dev/null +++ b/packages/solidity-contracts/contracts/test/MockPermitToken.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.9; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; + +/// @notice This token is for testing purposes. +contract MockPermitToken is ERC20, ERC20Permit { + address public _owner; + + /// @notice Constructor. + constructor() ERC20("Token", "TKN") ERC20Permit("Token") { + _owner = msg.sender; + } + + /// @notice This is a simple mint function. + /// @param owner The owner of the token. + /// @param amount The amount of the token to mint to the owner. + /// @dev Allows anyone to mint the token. + function mint(address owner, uint256 amount) external { + _mint(owner, amount); + } +} diff --git a/packages/solidity-contracts/contracts/utils/Permit.sol b/packages/solidity-contracts/contracts/utils/Permit.sol new file mode 100644 index 00000000..7be9547b --- /dev/null +++ b/packages/solidity-contracts/contracts/utils/Permit.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.9; + +/// @notice Structure for erc20 token permit signature +struct PermitSignature { + uint256 deadline; + uint8 v; + bytes32 r; + bytes32 s; +} diff --git a/packages/solidity-contracts/test/behaviors/erc20GatewayV4.behavior.test.ts b/packages/solidity-contracts/test/behaviors/erc20GatewayV4.behavior.test.ts index c1a884e6..0cc6dab8 100644 --- a/packages/solidity-contracts/test/behaviors/erc20GatewayV4.behavior.test.ts +++ b/packages/solidity-contracts/test/behaviors/erc20GatewayV4.behavior.test.ts @@ -24,6 +24,7 @@ import { randomAddress, randomBytes32 } from '../../protocol/utils'; import { CustomToken__factory, NoDecimalsToken__factory, + MockPermitToken, } from '../../typechain'; import type { MockFuelMessagePortal, @@ -61,6 +62,62 @@ const MessagePayloadSolidityTypes = [ 'uint256', // decimals ]; +async function buildPermitParams( + name: string, + tokenAddress: string, + gatewayAddress: string, + amount: bigint, + nonce: bigint, + deadline: number, + deployer: HardhatEthersSigner +) { + const domain: any = { + name: name, + version: '1', + chainId: hre.network.config.chainId, + verifyingContract: tokenAddress, + }; + + const types: any = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], + }; + + const values: any = { + owner: await deployer.getAddress(), + spender: gatewayAddress, + value: amount.toString(), + nonce: nonce.toString(), + deadline: deadline.toString(), + }; + + return { domain, types, values }; +} + +function parseSignature(signature: string) { + signature = signature.startsWith('0x') ? signature.slice(2) : signature; + + // Ensure the signature length is correct + if (signature.length !== 130) { + throw new Error('Invalid signature length!'); + } + // Extract R, S, V + const r = '0x' + signature.slice(0, 64); + const s = '0x' + signature.slice(64, 128); + const v = parseInt(signature.slice(128, 130), 16); + // Return formatted values + return { + r, + s, + v, + }; +} + export function behavesLikeErc20GatewayV4(fixture: () => Promise) { describe('Behaves like FuelERC20GatewayV4', () => { let env: Env; @@ -179,7 +236,6 @@ export function behavesLikeErc20GatewayV4(fixture: () => Promise) { erc20Gateway, signers: [deployer, mallory], } = env; - const pauserRole = await erc20Gateway.PAUSER_ROLE(); const tx = erc20Gateway.connect(mallory).pause(); @@ -965,6 +1021,124 @@ export function behavesLikeErc20GatewayV4(fixture: () => Promise) { }); }); + describe('deposit with permit token', () => { + let permitToken: MockPermitToken; + const deadline = Math.floor(Date.now() / 1000) + 600; // 10 mins from current timestamp + + beforeEach('deploy permit token', async () => { + const PermitTokenFactory = await hre.ethers.getContractFactory( + 'MockPermitToken' + ); + + permitToken = await PermitTokenFactory.deploy(); + }); + + it('reverts when a user tries to deposit with a token without permit feature', async () => { + const { + erc20Gateway, + token, + signers: [deployer, user], + } = env; + + const depositAmount = parseUnits('10', 18); + const depositTo = randomBytes32(); + + await token.connect(deployer).mint(user, depositAmount); + await token.connect(user).approve(erc20Gateway, MaxUint256); + + const tx = erc20Gateway + .connect(user) + .depositWithPermit(depositTo, token, depositAmount, { + deadline: 0, + v: 0, + r: '0x0000000000000000000000000000000000000000000000000000000000000000', + s: '0x0000000000000000000000000000000000000000000000000000000000000000', + }); + await expect(tx).to.be.revertedWithCustomError( + erc20Gateway, + 'PermitNotAllowed()' + ); + }); + + it('reverts when permit fails when there is no approval', async () => { + const { + erc20Gateway, + token, + signers: [deployer, user], + } = env; + + const depositAmount = parseUnits('10', 18); + const depositTo = randomBytes32(); + + await token.connect(deployer).mint(user, depositAmount); + + const tx = erc20Gateway + .connect(user) + .depositWithPermit(depositTo, token, depositAmount, { + deadline, + v: 0, + r: '0x0000000000000000000000000000000000000000000000000000000000000000', + s: '0x0000000000000000000000000000000000000000000000000000000000000000', + }); + await expect(tx).to.be.revertedWith('ERC20: insufficient allowance'); + }); + + it('user is able to deposit without calling approve()', async () => { + const { + erc20Gateway, + signers: [deployer, user], + } = env; + + const depositAmount = parseUnits('10', 18); + const depositTo = randomBytes32(); + + const tokenOwnerAddress = await deployer.getAddress(); + + await permitToken.connect(deployer).mint(user, depositAmount); + + const tokenName = await permitToken.name(); + const tokenAddress = await permitToken.getAddress(); + const gatewayAddress = await erc20Gateway.getAddress(); + const deployerNonce = await permitToken.nonces(tokenOwnerAddress); + + const signatureParams = await buildPermitParams( + tokenName, + tokenAddress, + gatewayAddress, + depositAmount, + deployerNonce, + deadline, + user + ); + + const signature = await user.signTypedData( + signatureParams.domain, + signatureParams.types, + signatureParams.values + ); + + const { r, s, v } = parseSignature(signature); + const permitData = { + deadline, + v, + r, + s, + }; + + const tx = erc20Gateway + .connect(user) + .depositWithPermit(depositTo, permitToken, depositAmount, permitData); + + await expect(tx) + .to.emit(erc20Gateway, 'Deposit') + .withArgs( + zeroPadValue(await user.getAddress(), 32), + permitToken, + depositAmount + ); + }); + }); + describe('depositWithData', () => { it('reverts when paused'); it('reverts when whitelist is required'); diff --git a/packages/test-utils/src/utils/ethers/getOrDeployERC20PermitContract.ts b/packages/test-utils/src/utils/ethers/getOrDeployERC20PermitContract.ts new file mode 100644 index 00000000..9170e09e --- /dev/null +++ b/packages/test-utils/src/utils/ethers/getOrDeployERC20PermitContract.ts @@ -0,0 +1,73 @@ +import type { MockPermitToken } from '@fuel-bridge/solidity-contracts/typechain'; +import { MockPermitToken__factory } from '@fuel-bridge/solidity-contracts/typechain'; + +import { debug } from '../logs'; +import { ethers_parseToken } from '../parsers'; +import type { TestEnvironment } from '../setup'; + +const { ETH_ERC20_TOKEN_ADDRESS } = process.env; + +export async function getOrDeployERC20PermitContract(env: TestEnvironment) { + debug('Setting up environment...'); + const ethDeployer = env.eth.signers[0]; + const ethDeployerAddr = await ethDeployer.getAddress(); + const ethAcct = env.eth.signers[0]; + + // load ERC20 contract + let ethTestToken: MockPermitToken = null; + if (ETH_ERC20_TOKEN_ADDRESS) { + try { + ethTestToken = MockPermitToken__factory.connect( + ETH_ERC20_TOKEN_ADDRESS, + ethDeployer + ); + const tokenOwner = await ethTestToken._owner(); + if (tokenOwner.toLowerCase() != ethDeployerAddr.toLowerCase()) { + ethTestToken = null; + debug( + `The Ethereum ERC-20 token at ${ETH_ERC20_TOKEN_ADDRESS} is not owned by the Ethereum deployer ${ethDeployerAddr}.` + ); + } + } catch (e) { + ethTestToken = null; + debug( + `The Ethereum ERC-20 token could not be found at the provided address ${ETH_ERC20_TOKEN_ADDRESS}.` + ); + } + } + if (!ethTestToken) { + debug(`Creating ERC-20 token contract to test with...`); + const eth_tokenFactory = new MockPermitToken__factory(ethDeployer); + ethTestToken = await eth_tokenFactory + .deploy() + .then((tx) => tx.waitForDeployment()); + debug( + `Ethereum ERC-20 token contract created at address ${await ethTestToken.getAddress()}.` + ); + } + ethTestToken = ethTestToken.connect(ethAcct); + const ethTestTokenAddress = await ethTestToken.getAddress(); + debug( + `Testing with Ethereum ERC-20 token contract at ${ethTestTokenAddress}.` + ); + + return ethTestToken; +} + +export async function mintECR20PermitToken( + env: TestEnvironment, + ethTestToken: MockPermitToken, + ethAcctAddr: string, + amount: string +) { + if ( + (await ethTestToken.balanceOf(ethAcctAddr)) <= + ethers_parseToken(amount, 18n) * 2n + ) { + debug(`Minting ERC-20 tokens to test with...`); + const tokenMintTx1 = await ethTestToken + .connect(env.eth.deployer) + .mint(ethAcctAddr, ethers_parseToken('100', 18n)); + await tokenMintTx1.wait(); + } +} diff --git a/packages/test-utils/src/utils/ethers/index.ts b/packages/test-utils/src/utils/ethers/index.ts index e9225a2e..f7d47c5a 100644 --- a/packages/test-utils/src/utils/ethers/index.ts +++ b/packages/test-utils/src/utils/ethers/index.ts @@ -1,6 +1,7 @@ export * from './callEtherRPC'; export * from './createRelayParams'; export * from './getOrDeployECR20Contract'; +export * from './getOrDeployERC20PermitContract'; export * from './getOrDeployERC721Contract'; export * from './waitForBlockCommit'; export * from './waitForBlockFinalization';