From 9243298208ca04928d8da4dc7b0c6afc2f6ea47b Mon Sep 17 00:00:00 2001 From: todd <81545601+todd-woko@users.noreply.github.com> Date: Sat, 12 Oct 2024 10:10:10 +0800 Subject: [PATCH] feat(solidity): bridge fee quote contract --- solidity/contracts/bridge/BridgeFeeOracle.sol | 94 +++++ solidity/contracts/bridge/BridgeFeeQuote.sol | 368 ++++++++++++++++++ solidity/contracts/bridge/IBridgeFee.sol | 50 +++ .../contracts/test/BridgeFeeQuoteTest.sol | 31 ++ solidity/test/bridge_fee_quote.ts | 289 ++++++++++++++ 5 files changed, 832 insertions(+) create mode 100644 solidity/contracts/bridge/BridgeFeeOracle.sol create mode 100644 solidity/contracts/bridge/BridgeFeeQuote.sol create mode 100644 solidity/contracts/bridge/IBridgeFee.sol create mode 100644 solidity/contracts/test/BridgeFeeQuoteTest.sol create mode 100644 solidity/test/bridge_fee_quote.ts diff --git a/solidity/contracts/bridge/BridgeFeeOracle.sol b/solidity/contracts/bridge/BridgeFeeOracle.sol new file mode 100644 index 00000000..f745b58d --- /dev/null +++ b/solidity/contracts/bridge/BridgeFeeOracle.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import {IBridgeFeeOracle} from "./IBridgeFee.sol"; +import {ICrossChain} from "./ICrossChain.sol"; + +contract BridgeFeeOracle is + IBridgeFeeOracle, + Initializable, + UUPSUpgradeable, + AccessControlUpgradeable, + ReentrancyGuardUpgradeable +{ + bytes32 public constant QUOTE_ROLE = keccak256("QUOTE_ROLE"); + + address public crossChainContract; + address public defaultOracle; + + struct State { + bool isBlackListed; + bool isActive; + } + + address[] public oracles; + mapping(address => State) public oracleStatus; + + function initialize(address _crossChain) public initializer { + crossChainContract = _crossChain; + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + + __AccessControl_init(); + __UUPSUpgradeable_init(); + __ReentrancyGuard_init(); + } + + function isOnline( + string memory _chainName, + address _oracle + ) external onlyRole(QUOTE_ROLE) returns (bool) { + if (oracleStatus[_oracle].isActive) return true; + if (oracleStatus[_oracle].isBlackListed) return false; + if (!ICrossChain(crossChainContract).hasOracle(_chainName, _oracle)) { + return false; + } + if ( + !ICrossChain(crossChainContract).isOracleOnline(_chainName, _oracle) + ) { + return false; + } + oracleStatus[_oracle] = State(false, true); + oracles.push(_oracle); + return true; + } + + function blackOracle( + address _oracle + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + if (oracleStatus[_oracle].isBlackListed) return; + if (oracleStatus[_oracle].isActive) { + oracleStatus[_oracle].isActive = false; + removeOracle(_oracle); + } + oracleStatus[_oracle].isBlackListed = true; + } + + function removeOracle(address _oracle) internal { + for (uint256 i = 0; i < oracles.length; i++) { + if (oracles[i] == _oracle) { + oracles[i] = oracles[oracles.length - 1]; + oracles.pop(); + break; + } + } + } + + function setDefaultOracle( + address _defaultOracle + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + defaultOracle = _defaultOracle; + } + + function getOracleList() external view returns (address[] memory) { + return oracles; + } + + function _authorizeUpgrade( + address + ) internal override onlyRole(DEFAULT_ADMIN_ROLE) {} // solhint-disable-line no-empty-blocks +} diff --git a/solidity/contracts/bridge/BridgeFeeQuote.sol b/solidity/contracts/bridge/BridgeFeeQuote.sol new file mode 100644 index 00000000..357758b2 --- /dev/null +++ b/solidity/contracts/bridge/BridgeFeeQuote.sol @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity ^0.8.0; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import {IERC20MetadataUpgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/IERC20MetadataUpgradeable.sol"; +import {SafeERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/utils/SafeERC20Upgradeable.sol"; +import {ECDSAUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/ECDSAUpgradeable.sol"; +import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/security/ReentrancyGuardUpgradeable.sol"; +import {IBridgeFeeQuote} from "./IBridgeFee.sol"; +import {IBridgeFeeOracle} from "./IBridgeFee.sol"; + +error ChainNameInvalid(); +error TokenInvalid(); +error OracleInvalid(); +error QuoteExpired(); +error VerifySignatureFailed(address, address); +error QuoteNotFound(); +error ChainNameAlreadyExists(); +error TokenAlreadyExists(); + +contract BridgeFeeQuote is + IBridgeFeeQuote, + Initializable, + UUPSUpgradeable, + OwnableUpgradeable, + ReentrancyGuardUpgradeable +{ + using SafeERC20Upgradeable for IERC20MetadataUpgradeable; + using ECDSAUpgradeable for bytes32; + + struct Assert { + bool isActive; + address[] tokens; + } + + struct Quote { + uint256 id; + uint256 fee; + uint256 gasLimit; + uint256 expiry; + } + + address public oracleContract; + + uint256 public quoteNonce; + + string[] public chainNames; + + // chainName -> Assert + mapping(string => Assert) public assets; + + // Only one quote is allowed per chainName + token + oracle + mapping(bytes => Quote) internal quotes; // key: chainName + token + oracle + // id -> chainName + token + oracle + mapping(uint256 => bytes) internal quoteIds; + + function initialize(address _oracleContract) public initializer { + oracleContract = _oracleContract; + + __Ownable_init(); + __UUPSUpgradeable_init(); + __ReentrancyGuard_init(); + } + + event NewQuote( + uint256 indexed id, + address indexed oracle, + string indexed chainName, + address token, + uint256 fee, + uint256 gasLimit, + uint256 expiry + ); + + function quote( + QuoteInput[] memory _inputs + ) external nonReentrant returns (bool) { + for (uint256 i = 0; i < _inputs.length; i++) { + verifyInput(_inputs[i]); + + verifySignature(_inputs[i]); + + bytes memory asset = packAsset( + _inputs[i].chainName, + _inputs[i].token, + _inputs[i].oracle + ); + if (quotes[asset].id > 0) { + delete quoteIds[quotes[asset].id]; + } + quotes[asset] = Quote({ + id: quoteNonce, + fee: _inputs[i].fee, + gasLimit: _inputs[i].gasLimit, + expiry: _inputs[i].expiry + }); + quoteIds[quoteNonce] = asset; + emit NewQuote( + quoteNonce, + _inputs[i].oracle, + _inputs[i].chainName, + _inputs[i].token, + _inputs[i].fee, + _inputs[i].gasLimit, + _inputs[i].expiry + ); + quoteNonce = quoteNonce + 1; + } + return true; + } + + // get quote list by chainName + function getQuoteList( + string memory _chainName + ) external view returns (QuoteInfo[] memory) { + if (!assets[_chainName].isActive) { + revert ChainNameInvalid(); + } + + QuoteInfo[] memory quotesList = new QuoteInfo[]( + currentActiveQuoteNum(_chainName) + ); + uint256 currentIndex = 0; + address[] memory oracles = IBridgeFeeOracle(oracleContract) + .getOracleList(); + + for (uint256 i = 0; i < oracles.length; i++) { + for (uint256 j = 0; j < assets[_chainName].tokens.length; j++) { + bytes memory asset = packAsset( + _chainName, + assets[_chainName].tokens[j], + oracles[i] + ); + if (quotes[asset].expiry >= block.timestamp) { + quotesList[currentIndex] = QuoteInfo({ + id: quotes[asset].id, + chainName: _chainName, + token: assets[_chainName].tokens[j], + oracle: oracles[i], + fee: quotes[asset].fee, + gasLimit: quotes[asset].gasLimit, + expiry: quotes[asset].expiry + }); + currentIndex += 1; + } + } + } + return quotesList; + } + + function getQuoteById( + uint256 _id + ) external view returns (QuoteInfo memory q) { + bytes memory asset = quoteIds[_id]; + if (asset.length == 0) { + revert QuoteNotFound(); + } + (string memory chainName, address token, address oracle) = unpackAsset( + asset + ); + q = QuoteInfo({ + id: _id, + chainName: chainName, + token: token, + oracle: oracle, + fee: quotes[asset].fee, + gasLimit: quotes[asset].gasLimit, + expiry: quotes[asset].expiry + }); + } + + function getQuoteByToken( + string memory _chainName, + address _token, + uint256 + ) external view returns (QuoteInfo memory, bool) { + if (!assets[_chainName].isActive) { + revert ChainNameInvalid(); + } + address oracle = IBridgeFeeOracle(oracleContract).defaultOracle(); + + bytes memory asset = packAsset(_chainName, _token, oracle); + return ( + QuoteInfo({ + id: quotes[asset].id, + chainName: _chainName, + token: _token, + oracle: oracle, + fee: quotes[asset].fee, + gasLimit: quotes[asset].gasLimit, + expiry: quotes[asset].expiry + }), + quotes[asset].expiry > block.timestamp + ); + } + + function currentActiveQuoteNum( + string memory _chainName + ) internal view returns (uint256) { + uint256 num = 0; + address[] memory oracles = IBridgeFeeOracle(oracleContract) + .getOracleList(); + for (uint256 i = 0; i < oracles.length; i++) { + for (uint256 j = 0; j < assets[_chainName].tokens.length; j++) { + bytes memory asset = packAsset( + _chainName, + assets[_chainName].tokens[j], + oracles[i] + ); + if (quotes[asset].expiry >= block.timestamp) { + num += 1; + } + } + } + return num; + } + + function verifyInput(QuoteInput memory _input) private { + if (!assets[_input.chainName].isActive) { + revert ChainNameInvalid(); + } + if (!isActiveToken(_input.chainName, _input.token)) { + revert TokenInvalid(); + } + if ( + !IBridgeFeeOracle(oracleContract).isOnline( + _input.chainName, + _input.oracle + ) + ) { + revert OracleInvalid(); + } + if (_input.expiry < block.timestamp) { + revert QuoteExpired(); + } + } + + function verifySignature(QuoteInput memory _input) private pure { + bytes32 hash = makeMessageHash( + _input.chainName, + _input.token, + _input.fee, + _input.gasLimit, + _input.expiry + ); + address signer = hash.toEthSignedMessageHash().recover( + _input.signature + ); + if (_input.oracle != signer) { + revert VerifySignatureFailed(_input.oracle, signer); + } + } + + function makeMessageHash( + string memory _chainName, + address _token, + uint256 _fee, + uint256 _gasLimit, + uint256 _expiry + ) public pure returns (bytes32) { + return + keccak256( + abi.encodePacked(_chainName, _token, _fee, _gasLimit, _expiry) + ); + } + + function packAsset( + string memory _chainName, + address _token, + address _oracle + ) internal pure returns (bytes memory) { + return abi.encode(_chainName, _token, _oracle); + } + + function unpackAsset( + bytes memory _packedData + ) + internal + pure + returns (string memory chainName, address token, address oracle) + { + (chainName, token, oracle) = abi.decode( + _packedData, + (string, address, address) + ); + } + + function isActiveToken( + string memory _chainName, + address _token + ) public view returns (bool) { + Assert memory asset = assets[_chainName]; + for (uint256 i = 0; i < asset.tokens.length; i++) { + if (asset.tokens[i] == _token) { + return asset.isActive; + } + } + return false; + } + + function activeTokens( + string memory _chainName + ) external view returns (address[] memory) { + return assets[_chainName].tokens; + } + + function hasActiveQuote(address _oracle) internal view returns (bool) { + for (uint256 i = 0; i < chainNames.length; i++) { + for (uint256 j = 0; j < assets[chainNames[i]].tokens.length; j++) { + bytes memory asset = packAsset( + chainNames[i], + assets[chainNames[i]].tokens[j], + _oracle + ); + if (quotes[asset].expiry >= block.timestamp) { + return true; + } + } + } + return false; + } + + function registerChain( + string memory _chainName, + address[] memory _tokens + ) external onlyOwner returns (bool) { + if (assets[_chainName].isActive) { + revert ChainNameAlreadyExists(); + } + assets[_chainName] = Assert({isActive: true, tokens: _tokens}); + chainNames.push(_chainName); + return true; + } + + function registerToken( + string memory _chainName, + address[] memory _tokens + ) external onlyOwner returns (bool) { + if (!assets[_chainName].isActive) { + revert ChainNameInvalid(); + } + for (uint256 i = 0; i < _tokens.length; i++) { + if (_tokens[i] == address(0)) { + revert TokenInvalid(); + } + if (isActiveToken(_chainName, _tokens[i])) { + revert TokenAlreadyExists(); + } + assets[_chainName].tokens.push(_tokens[i]); + } + + return true; + } + + function updateOracleContract( + address _oracleContract + ) external onlyOwner returns (bool) { + oracleContract = _oracleContract; + return true; + } + + receive() external payable {} + + // solhint-disable-next-line no-empty-blocks + function _authorizeUpgrade(address) internal override onlyOwner {} +} diff --git a/solidity/contracts/bridge/IBridgeFee.sol b/solidity/contracts/bridge/IBridgeFee.sol new file mode 100644 index 00000000..17f070b9 --- /dev/null +++ b/solidity/contracts/bridge/IBridgeFee.sol @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: UNLICENSED +/* solhint-disable one-contract-per-file */ +pragma solidity ^0.8.0; + +interface IBridgeFeeQuote { + struct QuoteInput { + string chainName; + address token; + address oracle; + uint256 fee; + uint256 gasLimit; + uint256 expiry; + bytes signature; + } + + struct QuoteInfo { + uint256 id; + string chainName; + address token; + address oracle; + uint256 fee; + uint256 gasLimit; + uint256 expiry; + } + + function quote(QuoteInput[] memory _inputs) external returns (bool); + + function getQuoteList( + string memory _chainName + ) external view returns (QuoteInfo[] memory); + + function getQuoteById(uint256 _id) external view returns (QuoteInfo memory); + + function getQuoteByToken( + string memory _chainName, + address _token, + uint256 _amount + ) external view returns (QuoteInfo memory, bool); +} + +interface IBridgeFeeOracle { + function defaultOracle() external view returns (address); + + function isOnline( + string memory _chainName, + address _oracle + ) external returns (bool); + + function getOracleList() external view returns (address[] memory); +} diff --git a/solidity/contracts/test/BridgeFeeQuoteTest.sol b/solidity/contracts/test/BridgeFeeQuoteTest.sol new file mode 100644 index 00000000..5762a9ca --- /dev/null +++ b/solidity/contracts/test/BridgeFeeQuoteTest.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.0; + +/* solhint-disable custom-errors */ + +contract BridgeFeeQuoteTest { + struct OracleState { + bool registered; + bool online; + } + + mapping(address => OracleState) public oracleStatus; + + function setOracle(address _oracle, OracleState memory _state) external { + oracleStatus[_oracle] = _state; + } + + function hasOracle( + string memory, + address _externalAddress + ) external view returns (bool _result) { + return oracleStatus[_externalAddress].registered; + } + + function isOracleOnline( + string memory, + address _externalAddress + ) external view returns (bool _result) { + return oracleStatus[_externalAddress].online; + } +} diff --git a/solidity/test/bridge_fee_quote.ts b/solidity/test/bridge_fee_quote.ts new file mode 100644 index 00000000..327cc027 --- /dev/null +++ b/solidity/test/bridge_fee_quote.ts @@ -0,0 +1,289 @@ +import { ethers } from "hardhat"; +import { expect } from "chai"; +import { AddressLike, HDNodeWallet, Wallet } from "ethers"; +import { + BridgeFeeOracle, + BridgeFeeQuote, + BridgeFeeQuoteTest, + IBridgeFeeQuote, +} from "../typechain-types"; +import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers"; + +const messagePrefix = "\x19Ethereum Signed Message:\n32"; + +describe("BridgeFeeQuoteUpgradeable", function () { + let bridgeFeeQuote: BridgeFeeQuote; + let bridgeFeeQuoteTest: BridgeFeeQuoteTest; + let bridgeFeeOracle: BridgeFeeOracle; + let chainName = "TestChain"; + let oracle: HDNodeWallet; + let owner: HardhatEthersSigner; + let token1: any; + let token2: any; + let tokens: AddressLike[]; + + beforeEach(async function () { + oracle = Wallet.createRandom(); + + [owner, token1, token2] = await ethers.getSigners(); + + const BridgeFeeQuoteTest = await ethers.getContractFactory( + "BridgeFeeQuoteTest" + ); + bridgeFeeQuoteTest = await BridgeFeeQuoteTest.deploy(); + const quoteTest = await bridgeFeeQuoteTest.getAddress(); + + const state: BridgeFeeQuoteTest.OracleStateStruct = { + registered: true, + online: true, + }; + + await bridgeFeeQuoteTest.setOracle(oracle.getAddress(), state); + + const BridgeFeeOracle = await ethers.getContractFactory("BridgeFeeOracle"); + + bridgeFeeOracle = await BridgeFeeOracle.deploy(); + await bridgeFeeOracle.initialize(bridgeFeeQuoteTest.getAddress()); + + const BridgeFeeQuote = await ethers.getContractFactory("BridgeFeeQuote"); + bridgeFeeQuote = await BridgeFeeQuote.deploy(); + + await bridgeFeeQuote.initialize(bridgeFeeOracle.getAddress()); + + const role = await bridgeFeeOracle.QUOTE_ROLE(); + await bridgeFeeOracle.grantRole(role, bridgeFeeQuote.getAddress()); + + tokens = [token1.address, token2.address]; + + await bridgeFeeQuote.registerChain(chainName, []); + await bridgeFeeQuote.registerToken(chainName, tokens); + }); + + describe("Oracle Management", function () { + it("should block an oracle correctly", async function () { + await bridgeFeeOracle.blackOracle(oracle.address); + const oracleStatus = await bridgeFeeOracle.oracleStatus(oracle.address); + expect(oracleStatus.isBlackListed).to.be.true; + }); + }); + + describe("Quote Management", function () { + it("should create a new quote", async function () { + const fee = 1; + const gasLimit = 0; + const expiry = (await currentTime()) + 3600; + + const input = await newBridgeFeeQuote( + chainName, + token1.address, + fee, + gasLimit, + expiry, + oracle + ); + + await expect(bridgeFeeQuote.quote([input])) + .to.be.emit(bridgeFeeQuote, "NewQuote") + .withArgs( + 0, + input.oracle, + chainName, + input.token, + input.fee, + input.gasLimit, + input.expiry + ); + + const quoteList = await bridgeFeeQuote.getQuoteList(chainName); + expect(quoteList.length).to.be.equal(1); + }); + + it("should revert when trying to get quotes for an inactive chain", async function () { + const chainName = ethers.encodeBytes32String("InactiveChain"); + await expect( + bridgeFeeQuote.getQuoteList(chainName) + ).to.be.revertedWithCustomError(bridgeFeeQuote, "ChainNameInvalid"); + }); + + it("should revert when trying to create a quote with an expired expiry", async function () { + const fee = 1; + const gasLimit = 0; + const expiry = (await currentTime()) - 3600; + + const signature = await generateSignature( + chainName, + token1.address, + fee, + gasLimit, + expiry, + oracle + ); + + const quoteInput: IBridgeFeeQuote.QuoteInputStruct = { + chainName: chainName, + token: token1.address, + oracle: oracle.address, + fee: fee, + gasLimit: gasLimit, + expiry: expiry, + signature: signature, + }; + await expect( + bridgeFeeQuote.quote([quoteInput]) + ).to.be.revertedWithCustomError(bridgeFeeQuote, "QuoteExpired"); + }); + + it("should revert when trying to create a quote without new oracle", async function () { + const fee = 1; + const gasLimit = 0; + const expiry = (await currentTime()) + 3600; + + const input = await newBridgeFeeQuote( + chainName, + token1.address, + fee, + gasLimit, + expiry, + oracle + ); + const input2 = await newBridgeFeeQuote( + chainName, + token2.address, + fee, + gasLimit, + expiry, + oracle + ); + await bridgeFeeQuote.quote([input, input2]); + + const quoteList = await bridgeFeeQuote.getQuoteList(chainName); + expect(quoteList.length).to.be.equal(2); + + const oracles = await bridgeFeeOracle.getOracleList(); + expect(oracles.length).to.be.equal(1); + }); + + it("test 1 ~ 5 quote gas limit", async function () { + const number = 5; + const fee = 1; + const gasLimit = 0; + const expiry = (await currentTime()) + 3600; + const singers = await ethers.getSigners(); + let tokens: AddressLike[] = []; + for (let i = 0; i < number; i++) { + tokens.push(singers[i + 10].address); + } + await bridgeFeeQuote.registerToken(chainName, tokens); + let quoteList: IBridgeFeeQuote.QuoteInputStruct[] = []; + for (let i = 0; i < number; i++) { + const input = await newBridgeFeeQuote( + chainName, + singers[i + 10].address, + fee, + gasLimit, + expiry, + oracle + ); + quoteList.push(input); + await bridgeFeeQuote.quote(quoteList); + + const quoteL = await bridgeFeeQuote.getQuoteList(chainName); + expect(quoteL.length).to.be.equal(i + 1); + } + }); + it("first oracle quote", async function () { + await bridgeFeeOracle.setDefaultOracle(oracle); + + const fee = 1; + const gasLimit = 0; + const expiry = (await currentTime()) + 3600; + + await bridgeFeeQuote.quote([ + { + chainName: chainName, + token: token1.address, + oracle: oracle.address, + fee: fee, + gasLimit: gasLimit, + expiry: expiry, + signature: await generateSignature( + chainName, + token1.address, + fee, + gasLimit, + expiry, + oracle + ), + }, + ]); + + const [quote, expire] = await bridgeFeeQuote.getQuoteByToken( + chainName, + token1.address, + 0 + ); + expect(expire).to.be.true; + expect(quote.fee).to.be.equal(fee); + expect(quote.gasLimit).to.be.equal(gasLimit); + }); + }); +}); + +async function generateSignature( + chainName: string, + token: string, + fee: number, + gasLimit: number, + expiry: number, + wallet: HDNodeWallet +): Promise { + const hash = ethers.solidityPackedKeccak256( + ["string", "address", "uint256", "uint256", "uint256"], + [chainName, token, fee, gasLimit, expiry] + ); + + const messageHash = ethers.solidityPackedKeccak256( + ["string", "bytes32"], + [messagePrefix, hash] + ); + + const signatureW = wallet.signingKey.sign(messageHash); + let v = "0x1b"; + if (signatureW.v === 28) { + v = "0x1c"; + } + return ethers.concat([signatureW.r, signatureW.s, v]); +} + +async function newBridgeFeeQuote( + chainName: string, + token: string, + fee: number, + gasLimit: number, + expiry: number, + oracle: HDNodeWallet +): Promise { + const signature = await generateSignature( + chainName, + token, + fee, + gasLimit, + expiry, + oracle + ); + return { + chainName: chainName, + token: token, + oracle: oracle.address, + fee: fee, + gasLimit: gasLimit, + expiry: expiry, + signature: signature, + }; +} + +async function currentTime(): Promise { + const blockNumber = await ethers.provider.getBlockNumber(); + const block = await ethers.provider.getBlock(blockNumber); + return block ? block.timestamp : Math.floor(Date.now() / 1000); +}