From ff814eb0a01f89d9a215f825d243bf421e6434a9 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Wed, 19 Feb 2025 16:56:22 +0100 Subject: [PATCH] [INTAUTO-272] Functions support zksync (#15991) * fix: point to 1.3.0 ocr * chore: add functions router zksync to solhint ignore * chore: add changeset * chore: removed not modified contracts * fix: remove l1 fee logic * fix: unused vars * Adds exact gas bound call implementation for zkSync This commit adds a safe implementation for the gas bound call implementation for zksync. It also adds a custom Functions router contract for zkSync. * Bump solidity version * Typo fix * Minor changes * Adds solidity version 0.8.20 * Addressing comments * Address feedback * Address feedback * Typo fix * Replaced require statements with custom error and other solhint recomendations * Downgrade shared solidity version to 0.8.20 * Set evm version to paris to fix tests * Fix comment * Auto detect shared solc version for shared * Uses rawCall and vendors zksync dependency * Adds tests and used near call pattern * Downgrade hardhat * Downgrade to solidity 0.8.19 * Adds zkSyncFunctionsRouter tests * Adds gas snapshots * Adds gas snapshot for shared * Solhint fixes * fix: call with exact gas * Adds more tests and addresses comments * Fixes comment and adjusts test * Updates gas snapshot * Adds pnpm lock file * Modifies comment * Modifies comment * Addresses comments * Updates gas snapshot * build CL image also on keystone changes --------- Co-authored-by: Kodey Kilday-Thomas Co-authored-by: Bartek Tofel Co-authored-by: Bartek Tofel --- .github/workflows/integration-tests.yml | 2 +- contracts/.changeset/loud-rabbits-chew.md | 5 + contracts/.solhintignore | 2 +- contracts/foundry.toml | 11 +- .../gas-snapshots/functions.gas-snapshot | 5 +- contracts/gas-snapshots/shared.gas-snapshot | 3 + .../src/v0.8/functions/tests/v1_X/Setup.t.sol | 40 +- .../tests/v1_X/ZKSyncFunctionsRouter.t.sol | 195 ++++++++ .../ZKSyncFunctionsRouterHarness.sol | 23 + .../v0.8/functions/v1_0_0/FunctionsRouter.sol | 2 +- .../v1_3_0_zksync/FunctionsBilling.sol | 435 ++++++++++++++++++ .../v1_3_0_zksync/FunctionsCoordinator.sol | 227 +++++++++ .../v1_3_0_zksync/ZKSyncFunctionsRouter.sol | 39 ++ .../shared/call/CallWithExactGasZKSync.sol | 103 +++++ .../test/call/CallWithExactGasZKSync.t.sol | 184 ++++++++ .../call/CallWithExactGasZKSyncHelper.sol | 19 + .../shared/test/mocks/MockSystemContext.sol | 127 +++++ .../shared/test/testhelpers/TestTarget.sol | 34 ++ .../contracts/ISystemContext.sol | 61 +++ 19 files changed, 1507 insertions(+), 10 deletions(-) create mode 100644 contracts/.changeset/loud-rabbits-chew.md create mode 100644 contracts/src/v0.8/functions/tests/v1_X/ZKSyncFunctionsRouter.t.sol create mode 100644 contracts/src/v0.8/functions/tests/v1_X/testhelpers/ZKSyncFunctionsRouterHarness.sol create mode 100644 contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsBilling.sol create mode 100644 contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsCoordinator.sol create mode 100644 contracts/src/v0.8/functions/v1_3_0_zksync/ZKSyncFunctionsRouter.sol create mode 100644 contracts/src/v0.8/shared/call/CallWithExactGasZKSync.sol create mode 100644 contracts/src/v0.8/shared/test/call/CallWithExactGasZKSync.t.sol create mode 100644 contracts/src/v0.8/shared/test/call/CallWithExactGasZKSyncHelper.sol create mode 100644 contracts/src/v0.8/shared/test/mocks/MockSystemContext.sol create mode 100644 contracts/src/v0.8/shared/test/testhelpers/TestTarget.sol create mode 100644 contracts/src/v0.8/vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 4dcf6234ccf..443df08f380 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -163,7 +163,7 @@ jobs: aws-region: ${{ secrets.AWS_REGION }} set-git-config: "true" - name: Build Chainlink Image - if: needs.changes.outputs.core_changes == 'true' || needs.changes.outputs.github_ci_changes == 'true' || github.event_name == 'workflow_dispatch' + if: needs.changes.outputs.core_changes == 'true' || needs.changes.outputs.keystone_changes == 'true' || needs.changes.outputs.github_ci_changes == 'true' || github.event_name == 'workflow_dispatch' uses: ./.github/actions/build-chainlink-image with: tag_suffix: ${{ matrix.image.tag-suffix }} diff --git a/contracts/.changeset/loud-rabbits-chew.md b/contracts/.changeset/loud-rabbits-chew.md new file mode 100644 index 00000000000..747c715881c --- /dev/null +++ b/contracts/.changeset/loud-rabbits-chew.md @@ -0,0 +1,5 @@ +--- +'@chainlink/contracts': patch +--- + +Added ZKSync support for Functions diff --git a/contracts/.solhintignore b/contracts/.solhintignore index f6613c95dea..42592eb947f 100644 --- a/contracts/.solhintignore +++ b/contracts/.solhintignore @@ -44,4 +44,4 @@ ./node_modules/ # Ignore tweaked vendored contracts -./src/v0.8/shared/enumerable/EnumerableSetWithBytes16.sol \ No newline at end of file +./src/v0.8/shared/enumerable/EnumerableSetWithBytes16.sol diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 578ac7e8e19..3be3cc41843 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -6,7 +6,7 @@ optimizer_runs = 1_000_000 src = 'src/v0.8' test = 'test/v0.8' out = 'foundry-artifacts' -cache_path = 'foundry-cache' +cache_path = 'foundry-cache' libs = ['node_modules'] bytecode_hash = "none" ffi = false @@ -42,9 +42,10 @@ via_ir = true [profile.functions] solc_version = '0.8.19' -src = 'src/v0.8/functions/dev/v1_X' -test = 'src/v0.8/functions/tests/v1_X' -gas_price = 3_000_000_000 # 3 gwei +evm_version = 'paris' +src = 'src/v0.8/functions' +test = 'src/v0.8/functions/tests' +gas_price = 3_000_000_000 # 3 gwei [profile.vrf] optimizer_runs = 1_000 @@ -101,7 +102,7 @@ optimizer_runs = 1_000_000 solc_version = '0.8.24' src = 'src/v0.8/workflow' test = 'src/v0.8/workflow/test' -via_ir = true # reconsider using the --via-ir flag if compilation takes too long +via_ir = true # reconsider using the --via-ir flag if compilation takes too long evm_version = 'paris' [profile.data-feeds] diff --git a/contracts/gas-snapshots/functions.gas-snapshot b/contracts/gas-snapshots/functions.gas-snapshot index 080ae2a4263..94400d96ad0 100644 --- a/contracts/gas-snapshots/functions.gas-snapshot +++ b/contracts/gas-snapshots/functions.gas-snapshot @@ -238,4 +238,7 @@ Gas_FulfillRequest_Success:test_FulfillRequest_Success_MaximumGas() (gas: 498189 Gas_FulfillRequest_Success:test_FulfillRequest_Success_MinimumGas() (gas: 199381) Gas_FundSubscription:test_FundSubscription_Gas() (gas: 35545) Gas_SendRequest:test_SendRequest_MaximumGas() (gas: 981365) -Gas_SendRequest:test_SendRequest_MinimumGas() (gas: 178588) \ No newline at end of file +Gas_SendRequest:test_SendRequest_MinimumGas() (gas: 178588) +ZKSyncFunctionsRouter__Callback:test__callback_PubdataUsage_IsZero() (gas: 45769) +ZKSyncFunctionsRouter__Callback:test__callback_ReturnDataTruncation() (gas: 584931) +ZKSyncFunctionsRouter__Callback:test__callback_Success() (gas: 40113) \ No newline at end of file diff --git a/contracts/gas-snapshots/shared.gas-snapshot b/contracts/gas-snapshots/shared.gas-snapshot index fc8096cbb5d..66d1c910850 100644 --- a/contracts/gas-snapshots/shared.gas-snapshot +++ b/contracts/gas-snapshots/shared.gas-snapshot @@ -45,6 +45,9 @@ BurnMintERC677_mint:testSenderNotMinterReverts() (gas: 11195) BurnMintERC677_supportsInterface:testConstructorSuccess() (gas: 12476) BurnMintERC677_transfer:testInvalidAddressReverts() (gas: 10639) BurnMintERC677_transfer:testTransferSuccess() (gas: 42299) +CallWithExactGasZKSync__callWithExactGasSafeReturnData:test__callWithExactGasSafeReturnData_FailsWhen_NotEnoughGasForCall() (gas: 18544) +CallWithExactGasZKSync__callWithExactGasSafeReturnData:test__callWithExactGasSafeReturnData_Success() (gas: 27871) +CallWithExactGasZKSync__callWithExactGasSafeReturnData:test__callWithExactGasSafeReturnData_TruncatesData() (gas: 75408) CallWithExactGas__callWithExactGas:test_CallWithExactGasReceiverErrorSuccess() (gas: 65949) CallWithExactGas__callWithExactGas:test_CallWithExactGasSafeReturnDataExactGas() (gas: 18324) CallWithExactGas__callWithExactGas:test_NoContractReverts() (gas: 11559) diff --git a/contracts/src/v0.8/functions/tests/v1_X/Setup.t.sol b/contracts/src/v0.8/functions/tests/v1_X/Setup.t.sol index f0069231e87..ff2ca42d303 100644 --- a/contracts/src/v0.8/functions/tests/v1_X/Setup.t.sol +++ b/contracts/src/v0.8/functions/tests/v1_X/Setup.t.sol @@ -3,6 +3,8 @@ pragma solidity ^0.8.19; import {BaseTest} from "./BaseTest.t.sol"; import {FunctionsClientHarness} from "./testhelpers/FunctionsClientHarness.sol"; +import {ZKSyncFunctionsRouterHarness, ZKSyncFunctionsRouter} from "./testhelpers/ZKSyncFunctionsRouterHarness.sol"; +import {FunctionsRouter as FunctionsRouterStable} from "../../v1_0_0/FunctionsRouter.sol"; import {FunctionsRouterHarness, FunctionsRouter} from "./testhelpers/FunctionsRouterHarness.sol"; import {FunctionsCoordinatorHarness} from "./testhelpers/FunctionsCoordinatorHarness.sol"; import {FunctionsBilling} from "../../dev/v1_X/FunctionsBilling.sol"; @@ -105,6 +107,42 @@ contract FunctionsRouterSetup is BaseTest { } } +/// @notice Set up to deploy the following contracts: FunctionsRouter, FunctionsCoordinator, LINK/ETH Feed, ToS Allow List, and LINK token +contract ZKSyncFunctionsRouterSetup is BaseTest { + ZKSyncFunctionsRouterHarness internal s_functionsRouter; + MockLinkToken internal s_linkToken; + + uint16 internal s_maxConsumersPerSubscription = 3; + uint72 internal s_adminFee = 0; // Keep as 0. Setting this to anything else will cause fulfillments to fail with INVALID_COMMITMENT + bytes4 internal s_handleOracleFulfillmentSelector = 0x0ca76175; + uint16 s_subscriptionDepositMinimumRequests = 1; + uint72 s_subscriptionDepositJuels = 11 * JUELS_PER_LINK; + + function setUp() public virtual override { + BaseTest.setUp(); + s_linkToken = new MockLinkToken(); + s_functionsRouter = new ZKSyncFunctionsRouterHarness(address(s_linkToken), this.getRouterConfig()); + } + + function getRouterConfig() public view returns (FunctionsRouterStable.Config memory) { + uint32[] memory maxCallbackGasLimits = new uint32[](3); + maxCallbackGasLimits[0] = 300_000; + maxCallbackGasLimits[1] = 500_000; + maxCallbackGasLimits[2] = 1_000_000; + + return + FunctionsRouterStable.Config({ + maxConsumersPerSubscription: s_maxConsumersPerSubscription, + adminFee: s_adminFee, + handleOracleFulfillmentSelector: s_handleOracleFulfillmentSelector, + maxCallbackGasLimits: maxCallbackGasLimits, + gasForCallExactCheck: 5000, + subscriptionDepositMinimumRequests: s_subscriptionDepositMinimumRequests, + subscriptionDepositJuels: s_subscriptionDepositJuels + }); + } +} + /// @notice Set up to set the OCR configuration of the Coordinator contract contract FunctionsDONSetup is FunctionsRouterSetup { uint256 internal NOP_SIGNER_PRIVATE_KEY_1 = 0x400; @@ -176,7 +214,7 @@ contract FunctionsDONSetup is FunctionsRouterSetup { ]; } - function _assertTransmittersAllHaveBalance(uint256[4] memory balances, uint256 expectedBalance) internal { + function _assertTransmittersAllHaveBalance(uint256[4] memory balances, uint256 expectedBalance) internal pure { assertEq(balances[0], expectedBalance); assertEq(balances[1], expectedBalance); assertEq(balances[2], expectedBalance); diff --git a/contracts/src/v0.8/functions/tests/v1_X/ZKSyncFunctionsRouter.t.sol b/contracts/src/v0.8/functions/tests/v1_X/ZKSyncFunctionsRouter.t.sol new file mode 100644 index 00000000000..8fc83bf5be5 --- /dev/null +++ b/contracts/src/v0.8/functions/tests/v1_X/ZKSyncFunctionsRouter.t.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {BaseTest} from "./BaseTest.t.sol"; +import {ZKSyncFunctionsRouter} from "../../v1_3_0_zksync/ZKSyncFunctionsRouter.sol"; +import {FunctionsRouter} from "../../v1_0_0/FunctionsRouter.sol"; +import {ZKSyncFunctionsRouterHarness} from "./testhelpers/ZKSyncFunctionsRouterHarness.sol"; +import {ZKSyncFunctionsRouterSetup} from "./Setup.t.sol"; +import {MockSystemContext} from "../../../shared/test/mocks/MockSystemContext.sol"; + +contract ZKSyncFunctionsRouter__Callback is ZKSyncFunctionsRouterSetup { + MockClientSuccess internal s_mockClientSuccess; + MockClientRevert internal s_mockClientRevert; + MockSystemContext internal s_mockSystemContext; + + struct CallbackResult { + bool success; + uint256 gasUsed; + bytes returnData; + } + + function setUp() public virtual override { + super.setUp(); + s_mockClientSuccess = new MockClientSuccess(); + s_mockClientRevert = new MockClientRevert(); + + s_mockSystemContext = new MockSystemContext(); + // Write mock's code to 0x800b so that library calls see it + vm.etch(address(0x800b), address(s_mockSystemContext).code); + } + + function test__callback_RevertWhen_NoClientCode() public { + bytes32 reqId = bytes32("reqIdNoCode"); + bytes memory resp = bytes("responseData"); + bytes memory err = bytes("errData"); + uint32 totalGas = 5_000_000; + uint32 callbackGasLimit = 4_000_000; + address noCodeAddress = address(12345); + + ZKSyncFunctionsRouter.CallbackResult memory result = _callback( + reqId, + resp, + err, + totalGas, + callbackGasLimit, + noCodeAddress + ); + + assertFalse(result.success, "Should skip => success=false"); + assertEq(result.gasUsed, 0, "gasUsed=0 for skip"); + assertEq(result.returnData.length, 0, "no return data"); + } + + function test__callback_Success() public { + bytes32 reqId = bytes32("reqSuccess"); + bytes memory resp = bytes("responseData"); + bytes memory err = bytes("errData"); + uint32 totalGas = 5_000_000; + uint32 callbackGasLimit = 4_000_000; + address client = address(s_mockClientSuccess); + + ZKSyncFunctionsRouter.CallbackResult memory result = _callback( + reqId, + resp, + err, + totalGas, + callbackGasLimit, + client + ); + + assertTrue(result.success, "callback should succeed"); + assertGt(result.gasUsed, 0, "some gas used"); + assertTrue(result.returnData.length > 0, "client returns a bool => should have data"); + } + + function test__callback_RevertWhen_ClientReverts() public { + bytes32 reqId = bytes32("reqIdRevert"); + bytes memory resp = bytes("someResponse"); + bytes memory err = bytes("someErr"); + // Use a moderate gas limit so that we don't trigger the _maxTotalGas check. + uint32 totalGas = 5_000_000; + uint32 callbackGasLimit = 4_000_000; + address client = address(s_mockClientRevert); + ZKSyncFunctionsRouter.CallbackResult memory result = _callback( + reqId, + resp, + err, + totalGas, + callbackGasLimit, + client + ); + + assertFalse(result.success, "client revert => success=false"); + assertGt(result.gasUsed, 0, "some gas is consumed"); + // returnData should contain the revert reason "MockClientRevert" + assertTrue(result.returnData.length > 0, "contains revert reason data"); + } + + /// @notice Example test verifying pubdata usage is zero and comparing internal measurement with external gas usage. + function test__callback_PubdataUsage_IsZero() public { + s_mockSystemContext.setGasPerPubdataByte(0); + bytes32 reqId = bytes32("reqPubdata"); + bytes memory resp = bytes("someResponse"); + bytes memory err = bytes("someErr"); + uint32 totalGas = 5_000_000; + uint32 callbackGasLimit = 4_000_000; + address client = address(s_mockClientSuccess); + uint256 startGas = gasleft(); + ZKSyncFunctionsRouter.CallbackResult memory result = _callback( + reqId, + resp, + err, + totalGas, + callbackGasLimit, + client + ); + uint256 endGas = gasleft(); + uint256 actualUsed = startGas - endGas; + assertTrue(result.success, "callback success"); + assertGt(result.gasUsed, 0, "callback claims >0 gas used"); + // Allow a margin between the router's internal measurement and actual external usage. + assertLe(result.gasUsed, actualUsed, "Router's gasUsed should not exceed actual external usage by large margin"); + } + + /// @notice Confirm large return data gets truncated by _maxReturnBytes. + function test__callback_ReturnDataTruncation() public { + // Deploy large-return client. + MockClientLargeReturn bigClient = new MockClientLargeReturn(); + + bytes32 reqId = bytes32("reqLargeReturn"); + bytes memory resp = bytes("someResponse"); + bytes memory err = bytes("someErr"); + uint32 totalGas = 5_000_000; + uint32 callbackGasLimit = 4_000_000; + address client = address(bigClient); + + ZKSyncFunctionsRouter.CallbackResult memory result = _callback( + reqId, + resp, + err, + totalGas, + callbackGasLimit, + client + ); + assertTrue(result.success, "Should succeed"); + uint256 expectedMax = s_functionsRouter.MAX_CALLBACK_RETURN_BYTES(); + // The returned data should be truncated exactly to expectedMax. + assertEq(result.returnData.length, expectedMax, "Should truncate data to MAX_CALLBACK_RETURN_BYTES"); + } + + /// @notice Internal helper to call the router's exposed callback function. + function _callback( + bytes32 reqId, + bytes memory resp, + bytes memory err, + uint32 totalGas, + uint32 callbackGasLimit, + address client + ) internal returns (FunctionsRouter.CallbackResult memory) { + bytes memory payload = abi.encodeWithSelector( + s_functionsRouter.exposed_callback.selector, + reqId, + resp, + err, + callbackGasLimit, + client + ); + (bool ok, bytes memory retData) = address(s_functionsRouter).call{gas: totalGas}(payload); + assertTrue(ok, "callback should succeed"); + return abi.decode(retData, (FunctionsRouter.CallbackResult)); + } +} + +contract MockClientSuccess { + function handleOracleFulfillment(bytes32, bytes memory, bytes memory) external pure returns (bool) { + return true; + } +} + +contract MockClientLargeReturn { + function handleOracleFulfillment(bytes32, bytes memory, bytes memory) external pure returns (bytes memory) { + // Return ~1,000 bytes. + bytes memory largeData = new bytes(1000); + for (uint i = 0; i < 1000; i++) { + largeData[i] = bytes1(uint8(65 + (i % 26))); // Fill with A..Z. + } + return largeData; + } +} + +contract MockClientRevert { + function handleOracleFulfillment(bytes32, bytes memory, bytes memory) external pure returns (bool) { + revert("MockClientRevert"); + } +} diff --git a/contracts/src/v0.8/functions/tests/v1_X/testhelpers/ZKSyncFunctionsRouterHarness.sol b/contracts/src/v0.8/functions/tests/v1_X/testhelpers/ZKSyncFunctionsRouterHarness.sol new file mode 100644 index 00000000000..296213dc8db --- /dev/null +++ b/contracts/src/v0.8/functions/tests/v1_X/testhelpers/ZKSyncFunctionsRouterHarness.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ZKSyncFunctionsRouter} from "../../../v1_3_0_zksync/ZKSyncFunctionsRouter.sol"; + +import {FunctionsRouter} from "../../../v1_0_0/FunctionsRouter.sol"; + +/// @title ZKSync Functions Router Test Harness +/// @notice Contract to expose internal functions for testing purposes +contract ZKSyncFunctionsRouterHarness is ZKSyncFunctionsRouter { + constructor(address linkToken, FunctionsRouter.Config memory config) ZKSyncFunctionsRouter(linkToken, config) {} + + function exposed_callback( + bytes32 requestId, + bytes memory response, + bytes memory err, + uint32 callbackGasLimit, + address client + ) public returns (CallbackResult memory) { + // simply call the internal `_callback` method + return super._callback(requestId, response, err, callbackGasLimit, client); + } +} diff --git a/contracts/src/v0.8/functions/v1_0_0/FunctionsRouter.sol b/contracts/src/v0.8/functions/v1_0_0/FunctionsRouter.sol index 9f35c4dfe51..8f42fc0fca3 100644 --- a/contracts/src/v0.8/functions/v1_0_0/FunctionsRouter.sol +++ b/contracts/src/v0.8/functions/v1_0_0/FunctionsRouter.sol @@ -400,7 +400,7 @@ contract FunctionsRouter is IFunctionsRouter, FunctionsSubscriptions, Pausable, bytes memory err, uint32 callbackGasLimit, address client - ) private returns (CallbackResult memory) { + ) internal virtual returns (CallbackResult memory) { bool destinationNoLongerExists; assembly { // solidity calls check that a contract actually exists at the destination, so we do the same diff --git a/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsBilling.sol b/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsBilling.sol new file mode 100644 index 00000000000..5f9d16ef680 --- /dev/null +++ b/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsBilling.sol @@ -0,0 +1,435 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IFunctionsSubscriptions} from "../v1_0_0/interfaces/IFunctionsSubscriptions.sol"; +import {AggregatorV3Interface} from "../../shared/interfaces/AggregatorV3Interface.sol"; +import {IFunctionsBilling, FunctionsBillingConfig} from "../v1_3_0/interfaces/IFunctionsBilling.sol"; + +import {Routable} from "../v1_0_0/Routable.sol"; +import {FunctionsResponse} from "../v1_0_0/libraries/FunctionsResponse.sol"; + +import {SafeCast} from "../../vendor/openzeppelin-solidity/v4.8.3/contracts/utils/math/SafeCast.sol"; + +/// @title Functions Billing contract +/// @notice Contract that calculates payment from users to the nodes of the Decentralized Oracle Network (DON). +abstract contract FunctionsBilling is Routable, IFunctionsBilling { + using FunctionsResponse for FunctionsResponse.RequestMeta; + using FunctionsResponse for FunctionsResponse.Commitment; + using FunctionsResponse for FunctionsResponse.FulfillResult; + + uint256 private constant REASONABLE_GAS_PRICE_CEILING = 1_000_000_000_000_000; // 1 million gwei + + event RequestBilled( + bytes32 indexed requestId, + uint96 juelsPerGas, + uint256 l1FeeShareWei, + uint96 callbackCostJuels, + uint72 donFeeJuels, + uint72 adminFeeJuels, + uint72 operationFeeJuels + ); + + // ================================================================ + // | Request Commitment state | + // ================================================================ + + mapping(bytes32 requestId => bytes32 commitmentHash) private s_requestCommitments; + + event CommitmentDeleted(bytes32 requestId); + + FunctionsBillingConfig private s_config; + + event ConfigUpdated(FunctionsBillingConfig config); + + error UnsupportedRequestDataVersion(); + error InsufficientBalance(); + error InvalidSubscription(); + error UnauthorizedSender(); + error MustBeSubOwner(address owner); + error InvalidLinkWeiPrice(int256 linkWei); + error InvalidUsdLinkPrice(int256 usdLink); + error PaymentTooLarge(); + error NoTransmittersSet(); + error InvalidCalldata(); + + // ================================================================ + // | Balance state | + // ================================================================ + + mapping(address transmitter => uint96 balanceJuelsLink) private s_withdrawableTokens; + // Pool together collected DON fees + // Disperse them on withdrawal or change in OCR configuration + uint96 internal s_feePool; + + AggregatorV3Interface private s_linkToNativeFeed; + AggregatorV3Interface private s_linkToUsdFeed; + + // ================================================================ + // | Initialization | + // ================================================================ + constructor( + address router, + FunctionsBillingConfig memory config, + address linkToNativeFeed, + address linkToUsdFeed + ) Routable(router) { + s_linkToNativeFeed = AggregatorV3Interface(linkToNativeFeed); + s_linkToUsdFeed = AggregatorV3Interface(linkToUsdFeed); + + updateConfig(config); + } + + // ================================================================ + // | Configuration | + // ================================================================ + + /// @notice Gets the Chainlink Coordinator's billing configuration + /// @return config + function getConfig() external view returns (FunctionsBillingConfig memory) { + return s_config; + } + + /// @notice Sets the Chainlink Coordinator's billing configuration + /// @param config - See the contents of the FunctionsBillingConfig struct in IFunctionsBilling.sol for more information + function updateConfig(FunctionsBillingConfig memory config) public { + _onlyOwner(); + + s_config = config; + emit ConfigUpdated(config); + } + + // ================================================================ + // | Fee Calculation | + // ================================================================ + + /// @inheritdoc IFunctionsBilling + function getDONFeeJuels(bytes memory /* requestData */) public view override returns (uint72) { + // s_config.donFee is in cents of USD. Get Juel amount then convert to dollars. + return SafeCast.toUint72(_getJuelsFromUsd(s_config.donFeeCentsUsd) / 100); + } + + /// @inheritdoc IFunctionsBilling + function getOperationFeeJuels() public view override returns (uint72) { + // s_config.donFee is in cents of USD. Get Juel amount then convert to dollars. + return SafeCast.toUint72(_getJuelsFromUsd(s_config.operationFeeCentsUsd) / 100); + } + + /// @inheritdoc IFunctionsBilling + function getAdminFeeJuels() public view override returns (uint72) { + return _getRouter().getAdminFee(); + } + + /// @inheritdoc IFunctionsBilling + function getWeiPerUnitLink() public view returns (uint256) { + (, int256 weiPerUnitLink, , uint256 timestamp, ) = s_linkToNativeFeed.latestRoundData(); + // solhint-disable-next-line not-rely-on-time + if (s_config.feedStalenessSeconds < block.timestamp - timestamp && s_config.feedStalenessSeconds > 0) { + return s_config.fallbackNativePerUnitLink; + } + if (weiPerUnitLink <= 0) { + revert InvalidLinkWeiPrice(weiPerUnitLink); + } + return uint256(weiPerUnitLink); + } + + function _getJuelsFromWei(uint256 amountWei) private view returns (uint96) { + // (1e18 juels/link) * wei / (wei/link) = juels + // There are only 1e9*1e18 = 1e27 juels in existence, should not exceed uint96 (2^96 ~ 7e28) + return SafeCast.toUint96((1e18 * amountWei) / getWeiPerUnitLink()); + } + + /// @inheritdoc IFunctionsBilling + function getUsdPerUnitLink() public view returns (uint256, uint8) { + (, int256 usdPerUnitLink, , uint256 timestamp, ) = s_linkToUsdFeed.latestRoundData(); + // solhint-disable-next-line not-rely-on-time + if (s_config.feedStalenessSeconds < block.timestamp - timestamp && s_config.feedStalenessSeconds > 0) { + return (s_config.fallbackUsdPerUnitLink, s_config.fallbackUsdPerUnitLinkDecimals); + } + if (usdPerUnitLink <= 0) { + revert InvalidUsdLinkPrice(usdPerUnitLink); + } + return (uint256(usdPerUnitLink), s_linkToUsdFeed.decimals()); + } + + function _getJuelsFromUsd(uint256 amountUsd) private view returns (uint96) { + (uint256 usdPerLink, uint8 decimals) = getUsdPerUnitLink(); + // (usd) * (10**18 juels/link) * (10**decimals) / (link / usd) = juels + // There are only 1e9*1e18 = 1e27 juels in existence, should not exceed uint96 (2^96 ~ 7e28) + return SafeCast.toUint96((amountUsd * 10 ** (18 + decimals)) / usdPerLink); + } + + // ================================================================ + // | Cost Estimation | + // ================================================================ + + /// @inheritdoc IFunctionsBilling + function estimateCost( + uint64 subscriptionId, + bytes calldata data, + uint32 callbackGasLimit, + uint256 gasPriceWei + ) external view override returns (uint96) { + _getRouter().isValidCallbackGasLimit(subscriptionId, callbackGasLimit); + // Reasonable ceilings to prevent integer overflows + if (gasPriceWei > REASONABLE_GAS_PRICE_CEILING) { + revert InvalidCalldata(); + } + uint72 adminFee = getAdminFeeJuels(); + uint72 donFee = getDONFeeJuels(data); + uint72 operationFee = getOperationFeeJuels(); + return _calculateCostEstimate(callbackGasLimit, gasPriceWei, donFee, adminFee, operationFee); + } + + /// @notice Estimate the cost in Juels of LINK + // that will be charged to a subscription to fulfill a Functions request + // Gas Price can be overestimated to account for flucuations between request and response time + function _calculateCostEstimate( + uint32 callbackGasLimit, + uint256 gasPriceWei, + uint72 donFeeJuels, + uint72 adminFeeJuels, + uint72 operationFeeJuels + ) internal view returns (uint96) { + // If gas price is less than the minimum fulfillment gas price, override to using the minimum + if (gasPriceWei < s_config.minimumEstimateGasPriceWei) { + gasPriceWei = s_config.minimumEstimateGasPriceWei; + } + + uint256 gasPriceWithOverestimation = gasPriceWei + + ((gasPriceWei * s_config.fulfillmentGasPriceOverEstimationBP) / 10_000); + /// @NOTE: Basis Points are 1/100th of 1%, divide by 10_000 to bring back to original units + + uint256 executionGas = s_config.gasOverheadBeforeCallback + s_config.gasOverheadAfterCallback + callbackGasLimit; + uint96 estimatedGasReimbursementJuels = _getJuelsFromWei(gasPriceWithOverestimation * executionGas); + + uint96 feesJuels = uint96(donFeeJuels) + uint96(adminFeeJuels) + uint96(operationFeeJuels); + + return estimatedGasReimbursementJuels + feesJuels; + } + + // ================================================================ + // | Billing | + // ================================================================ + + /// @notice Initiate the billing process for an Functions request + /// @dev Only callable by the Functions Router + /// @param request - Chainlink Functions request data, see FunctionsResponse.RequestMeta for the structure + /// @return commitment - The parameters of the request that must be held consistent at response time + function _startBilling( + FunctionsResponse.RequestMeta memory request + ) internal returns (FunctionsResponse.Commitment memory commitment, uint72 operationFee) { + // Nodes should support all past versions of the structure + if (request.dataVersion > s_config.maxSupportedRequestDataVersion) { + revert UnsupportedRequestDataVersion(); + } + + uint72 donFee = getDONFeeJuels(request.data); + operationFee = getOperationFeeJuels(); + uint96 estimatedTotalCostJuels = _calculateCostEstimate( + request.callbackGasLimit, + tx.gasprice, + donFee, + request.adminFee, + operationFee + ); + + // Check that subscription can afford the estimated cost + if ((request.availableBalance) < estimatedTotalCostJuels) { + revert InsufficientBalance(); + } + + uint32 timeoutTimestamp = uint32(block.timestamp + s_config.requestTimeoutSeconds); + bytes32 requestId = keccak256( + abi.encode( + address(this), + request.requestingContract, + request.subscriptionId, + request.initiatedRequests + 1, + keccak256(request.data), + request.dataVersion, + request.callbackGasLimit, + estimatedTotalCostJuels, + timeoutTimestamp, + // solhint-disable-next-line avoid-tx-origin + tx.origin + ) + ); + + commitment = FunctionsResponse.Commitment({ + adminFee: request.adminFee, + coordinator: address(this), + client: request.requestingContract, + subscriptionId: request.subscriptionId, + callbackGasLimit: request.callbackGasLimit, + estimatedTotalCostJuels: estimatedTotalCostJuels, + timeoutTimestamp: timeoutTimestamp, + requestId: requestId, + donFee: donFee, + gasOverheadBeforeCallback: s_config.gasOverheadBeforeCallback, + gasOverheadAfterCallback: s_config.gasOverheadAfterCallback + }); + + s_requestCommitments[requestId] = keccak256(abi.encode(commitment)); + + return (commitment, operationFee); + } + + /// @notice Finalize billing process for an Functions request by sending a callback to the Client contract and then charging the subscription + /// @param requestId identifier for the request that was generated by the Registry in the beginBilling commitment + /// @param response response data from DON consensus + /// @param err error from DON consensus + /// @return result fulfillment result + /// @dev Only callable by a node that has been approved on the Coordinator + /// @dev simulated offchain to determine if sufficient balance is present to fulfill the request + function _fulfillAndBill( + bytes32 requestId, + bytes memory response, + bytes memory err, + bytes memory onchainMetadata, + bytes memory /* offchainMetadata TODO: use in getDonFee() for dynamic billing */ + ) internal returns (FunctionsResponse.FulfillResult) { + FunctionsResponse.Commitment memory commitment = abi.decode(onchainMetadata, (FunctionsResponse.Commitment)); + + uint256 gasOverheadWei = (commitment.gasOverheadBeforeCallback + commitment.gasOverheadAfterCallback) * tx.gasprice; + + // Gas overhead without callback + uint96 gasOverheadJuels = _getJuelsFromWei(gasOverheadWei); + uint96 juelsPerGas = _getJuelsFromWei(tx.gasprice); + + // The Functions Router will perform the callback to the client contract + (FunctionsResponse.FulfillResult resultCode, uint96 callbackCostJuels) = _getRouter().fulfill( + response, + err, + juelsPerGas, + // The following line represents: "cost without callback or admin fee, those will be added by the Router" + // But because the _offchain_ Commitment is using operation fee in the place of the admin fee, this now adds admin fee (actually operation fee) + // Admin fee is configured to 0 in the Router + gasOverheadJuels + commitment.donFee + commitment.adminFee, + msg.sender, + FunctionsResponse.Commitment({ + adminFee: 0, // The Router should have adminFee set to 0. If it does not this will cause fulfillments to fail with INVALID_COMMITMENT instead of carrying out incorrect bookkeeping. + coordinator: commitment.coordinator, + client: commitment.client, + subscriptionId: commitment.subscriptionId, + callbackGasLimit: commitment.callbackGasLimit, + estimatedTotalCostJuels: commitment.estimatedTotalCostJuels, + timeoutTimestamp: commitment.timeoutTimestamp, + requestId: commitment.requestId, + donFee: commitment.donFee, + gasOverheadBeforeCallback: commitment.gasOverheadBeforeCallback, + gasOverheadAfterCallback: commitment.gasOverheadAfterCallback + }) + ); + + // The router will only pay the DON on successfully processing the fulfillment + // In these two fulfillment results the user has been charged + // Otherwise, the Coordinator should hold on to the request commitment + if ( + resultCode == FunctionsResponse.FulfillResult.FULFILLED || + resultCode == FunctionsResponse.FulfillResult.USER_CALLBACK_ERROR + ) { + delete s_requestCommitments[requestId]; + // Reimburse the transmitter for the fulfillment gas cost + s_withdrawableTokens[msg.sender] += gasOverheadJuels + callbackCostJuels; + // Put donFee into the pool of fees, to be split later + // Saves on storage writes that would otherwise be charged to the user + s_feePool += commitment.donFee; + // Pay the operation fee to the Coordinator owner + s_withdrawableTokens[_owner()] += commitment.adminFee; // OperationFee is used in the slot for Admin Fee in the Offchain Commitment. Admin Fee is set to 0 in the Router (enforced by line 316 in FunctionsBilling.sol). + emit RequestBilled({ + requestId: requestId, + juelsPerGas: juelsPerGas, + l1FeeShareWei: 0, + callbackCostJuels: callbackCostJuels, + donFeeJuels: commitment.donFee, + // The following two lines are because of OperationFee being used in the Offchain Commitment + adminFeeJuels: 0, + operationFeeJuels: commitment.adminFee + }); + } + return resultCode; + } + + // ================================================================ + // | Request Timeout | + // ================================================================ + + /// @inheritdoc IFunctionsBilling + /// @dev Only callable by the Router + /// @dev Used by FunctionsRouter.sol during timeout of a request + function deleteCommitment(bytes32 requestId) external override onlyRouter { + // Delete commitment + delete s_requestCommitments[requestId]; + emit CommitmentDeleted(requestId); + } + + // ================================================================ + // | Fund withdrawal | + // ================================================================ + + /// @inheritdoc IFunctionsBilling + function oracleWithdraw(address recipient, uint96 amount) external { + _disperseFeePool(); + + if (amount == 0) { + amount = s_withdrawableTokens[msg.sender]; + } else if (s_withdrawableTokens[msg.sender] < amount) { + revert InsufficientBalance(); + } + s_withdrawableTokens[msg.sender] -= amount; + IFunctionsSubscriptions(address(_getRouter())).oracleWithdraw(recipient, amount); + } + + /// @inheritdoc IFunctionsBilling + /// @dev Only callable by the Coordinator owner + function oracleWithdrawAll() external { + _onlyOwner(); + _disperseFeePool(); + + address[] memory transmitters = _getTransmitters(); + + // Bounded by "maxNumOracles" on OCR2Abstract.sol + for (uint256 i = 0; i < transmitters.length; ++i) { + uint96 balance = s_withdrawableTokens[transmitters[i]]; + if (balance > 0) { + s_withdrawableTokens[transmitters[i]] = 0; + IFunctionsSubscriptions(address(_getRouter())).oracleWithdraw(transmitters[i], balance); + } + } + } + + // Overriden in FunctionsCoordinator, which has visibility into transmitters + function _getTransmitters() internal view virtual returns (address[] memory); + + // DON fees are collected into a pool s_feePool + // When OCR configuration changes, or any oracle withdraws, this must be dispersed + function _disperseFeePool() internal { + if (s_feePool == 0) { + return; + } + // All transmitters are assumed to also be observers + // Pay out the DON fee to all transmitters + address[] memory transmitters = _getTransmitters(); + uint256 numberOfTransmitters = transmitters.length; + if (numberOfTransmitters == 0) { + revert NoTransmittersSet(); + } + uint96 feePoolShare = s_feePool / uint96(numberOfTransmitters); + // Bounded by "maxNumOracles" on OCR2Abstract.sol + for (uint256 i = 0; i < numberOfTransmitters; ++i) { + s_withdrawableTokens[transmitters[i]] += feePoolShare; + } + s_feePool -= feePoolShare * uint96(numberOfTransmitters); + } + + // Overriden in FunctionsCoordinator.sol + function _onlyOwner() internal view virtual; + + // Used in FunctionsCoordinator.sol + function _isExistingRequest(bytes32 requestId) internal view returns (bool) { + return s_requestCommitments[requestId] != bytes32(0); + } + + // Overriden in FunctionsCoordinator.sol + function _owner() internal view virtual returns (address owner); +} diff --git a/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsCoordinator.sol b/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsCoordinator.sol new file mode 100644 index 00000000000..66802cc4923 --- /dev/null +++ b/contracts/src/v0.8/functions/v1_3_0_zksync/FunctionsCoordinator.sol @@ -0,0 +1,227 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {IFunctionsCoordinator} from "../v1_0_0/interfaces/IFunctionsCoordinator.sol"; +import {ITypeAndVersion} from "../../shared/interfaces/ITypeAndVersion.sol"; + +import {FunctionsBilling, FunctionsBillingConfig} from "./FunctionsBilling.sol"; +import {OCR2Base} from "../v1_3_0/ocr/OCR2Base.sol"; +import {FunctionsResponse} from "../v1_0_0/libraries/FunctionsResponse.sol"; + +/// @title Functions Coordinator contract +/// @notice Contract that nodes of a Decentralized Oracle Network (DON) interact with +contract FunctionsCoordinator is OCR2Base, IFunctionsCoordinator, FunctionsBilling { + using FunctionsResponse for FunctionsResponse.RequestMeta; + using FunctionsResponse for FunctionsResponse.Commitment; + using FunctionsResponse for FunctionsResponse.FulfillResult; + + /// @inheritdoc ITypeAndVersion + // solhint-disable-next-line chainlink-solidity/all-caps-constant-storage-variables + string public constant override typeAndVersion = "Functions Coordinator v1.3.0"; + + event OracleRequest( + bytes32 indexed requestId, + address indexed requestingContract, + address requestInitiator, + uint64 subscriptionId, + address subscriptionOwner, + bytes data, + uint16 dataVersion, + bytes32 flags, + uint64 callbackGasLimit, + FunctionsResponse.Commitment commitment + ); + event OracleResponse(bytes32 indexed requestId, address transmitter); + + error InconsistentReportData(); + error EmptyPublicKey(); + error UnauthorizedPublicKeyChange(); + + bytes private s_donPublicKey; + bytes private s_thresholdPublicKey; + + constructor( + address router, + FunctionsBillingConfig memory config, + address linkToNativeFeed, + address linkToUsdFeed + ) OCR2Base() FunctionsBilling(router, config, linkToNativeFeed, linkToUsdFeed) {} + + /// @inheritdoc IFunctionsCoordinator + function getThresholdPublicKey() external view override returns (bytes memory) { + if (s_thresholdPublicKey.length == 0) { + revert EmptyPublicKey(); + } + return s_thresholdPublicKey; + } + + /// @inheritdoc IFunctionsCoordinator + function setThresholdPublicKey(bytes calldata thresholdPublicKey) external override onlyOwner { + if (thresholdPublicKey.length == 0) { + revert EmptyPublicKey(); + } + s_thresholdPublicKey = thresholdPublicKey; + } + + /// @inheritdoc IFunctionsCoordinator + function getDONPublicKey() external view override returns (bytes memory) { + if (s_donPublicKey.length == 0) { + revert EmptyPublicKey(); + } + return s_donPublicKey; + } + + /// @inheritdoc IFunctionsCoordinator + function setDONPublicKey(bytes calldata donPublicKey) external override onlyOwner { + if (donPublicKey.length == 0) { + revert EmptyPublicKey(); + } + s_donPublicKey = donPublicKey; + } + + /// @dev check if node is in current transmitter list + function _isTransmitter(address node) internal view returns (bool) { + // Bounded by "maxNumOracles" on OCR2Abstract.sol + for (uint256 i = 0; i < s_transmitters.length; ++i) { + if (s_transmitters[i] == node) { + return true; + } + } + return false; + } + + /// @inheritdoc IFunctionsCoordinator + function startRequest( + FunctionsResponse.RequestMeta calldata request + ) external override onlyRouter returns (FunctionsResponse.Commitment memory commitment) { + uint72 operationFee; + (commitment, operationFee) = _startBilling(request); + + emit OracleRequest( + commitment.requestId, + request.requestingContract, + // solhint-disable-next-line avoid-tx-origin + tx.origin, + request.subscriptionId, + request.subscriptionOwner, + request.data, + request.dataVersion, + request.flags, + request.callbackGasLimit, + FunctionsResponse.Commitment({ + coordinator: commitment.coordinator, + client: commitment.client, + subscriptionId: commitment.subscriptionId, + callbackGasLimit: commitment.callbackGasLimit, + estimatedTotalCostJuels: commitment.estimatedTotalCostJuels, + timeoutTimestamp: commitment.timeoutTimestamp, + requestId: commitment.requestId, + donFee: commitment.donFee, + gasOverheadBeforeCallback: commitment.gasOverheadBeforeCallback, + gasOverheadAfterCallback: commitment.gasOverheadAfterCallback, + // The following line is done to use the Coordinator's operationFee in place of the Router's operation fee + // With this in place the Router.adminFee must be set to 0 in the Router. + adminFee: operationFee + }) + ); + + return commitment; + } + + /// @dev DON fees are pooled together. If the OCR configuration is going to change, these need to be distributed. + function _beforeSetConfig(uint8 /* _f */, bytes memory /* _onchainConfig */) internal override { + if (_getTransmitters().length > 0) { + _disperseFeePool(); + } + } + + /// @dev Used by FunctionsBilling.sol + function _getTransmitters() internal view override returns (address[] memory) { + return s_transmitters; + } + + function _beforeTransmit( + bytes calldata report + ) internal view override returns (bool shouldStop, DecodedReport memory decodedReport) { + ( + bytes32[] memory requestIds, + bytes[] memory results, + bytes[] memory errors, + bytes[] memory onchainMetadata, + bytes[] memory offchainMetadata + ) = abi.decode(report, (bytes32[], bytes[], bytes[], bytes[], bytes[])); + uint256 numberOfFulfillments = uint8(requestIds.length); + + if ( + numberOfFulfillments == 0 || + numberOfFulfillments != results.length || + numberOfFulfillments != errors.length || + numberOfFulfillments != onchainMetadata.length || + numberOfFulfillments != offchainMetadata.length + ) { + revert ReportInvalid("Fields must be equal length"); + } + + for (uint256 i = 0; i < numberOfFulfillments; ++i) { + if (_isExistingRequest(requestIds[i])) { + // If there is an existing request, validate report + // Leave shouldStop to default, false + break; + } + if (i == numberOfFulfillments - 1) { + // If the last fulfillment on the report does not exist, then all are duplicates + // Indicate that it's safe to stop to save on the gas of validating the report + shouldStop = true; + } + } + + return ( + shouldStop, + DecodedReport({ + requestIds: requestIds, + results: results, + errors: errors, + onchainMetadata: onchainMetadata, + offchainMetadata: offchainMetadata + }) + ); + } + + /// @dev Report hook called within OCR2Base.sol + function _report(DecodedReport memory decodedReport) internal override { + uint256 numberOfFulfillments = uint8(decodedReport.requestIds.length); + + // Bounded by "MaxRequestBatchSize" on the Job's ReportingPluginConfig + for (uint256 i = 0; i < numberOfFulfillments; ++i) { + FunctionsResponse.FulfillResult result = FunctionsResponse.FulfillResult( + _fulfillAndBill( + decodedReport.requestIds[i], + decodedReport.results[i], + decodedReport.errors[i], + decodedReport.onchainMetadata[i], + decodedReport.offchainMetadata[i] + ) + ); + + // Emit on successfully processing the fulfillment + // In these two fulfillment results the user has been charged + // Otherwise, the DON will re-try + if ( + result == FunctionsResponse.FulfillResult.FULFILLED || + result == FunctionsResponse.FulfillResult.USER_CALLBACK_ERROR + ) { + emit OracleResponse(decodedReport.requestIds[i], msg.sender); + } + } + } + + /// @dev Used in FunctionsBilling.sol + function _onlyOwner() internal view override { + _validateOwnership(); + } + + /// @dev Used in FunctionsBilling.sol + function _owner() internal view override returns (address owner) { + return this.owner(); + } +} diff --git a/contracts/src/v0.8/functions/v1_3_0_zksync/ZKSyncFunctionsRouter.sol b/contracts/src/v0.8/functions/v1_3_0_zksync/ZKSyncFunctionsRouter.sol new file mode 100644 index 00000000000..869e674c5fe --- /dev/null +++ b/contracts/src/v0.8/functions/v1_3_0_zksync/ZKSyncFunctionsRouter.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {FunctionsRouter} from "../v1_0_0/FunctionsRouter.sol"; +import {CallWithExactGasZKSync} from "../../shared/call/CallWithExactGasZKSync.sol"; + +/// +/// @title FunctionsRouterZkSync +/// @notice Specialized version of FunctionsRouter for zkSync that uses +/// CallWithExactGasZKSync to control callback gas usage. +/// +contract ZKSyncFunctionsRouter is FunctionsRouter { + constructor(address linkToken, FunctionsRouter.Config memory config) FunctionsRouter(linkToken, config) {} + + /// @dev Override the internal callback function to use CallWithExactGasZKSync + /// for controlling and measuring gas usage on zkSync. + function _callback( + bytes32 requestId, + bytes memory response, + bytes memory err, + uint32 callbackGasLimit, + address client + ) internal override returns (CallbackResult memory) { + if (client.code.length == 0) { + // If there's no code at `client`, skip the callback + return CallbackResult({success: false, gasUsed: 0, returnData: new bytes(0)}); + } + uint256 g1 = gasleft(); + + (bool success, bytes memory returnData, uint256 pubdataGasSpent) = CallWithExactGasZKSync + ._callWithExactGasSafeReturnData( + client, + callbackGasLimit, + abi.encodeWithSelector(this.getConfig().handleOracleFulfillmentSelector, requestId, response, err), + MAX_CALLBACK_RETURN_BYTES + ); + return CallbackResult({success: success, gasUsed: g1 - gasleft() + pubdataGasSpent, returnData: returnData}); + } +} diff --git a/contracts/src/v0.8/shared/call/CallWithExactGasZKSync.sol b/contracts/src/v0.8/shared/call/CallWithExactGasZKSync.sol new file mode 100644 index 00000000000..5d0c2f1b97c --- /dev/null +++ b/contracts/src/v0.8/shared/call/CallWithExactGasZKSync.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {ISystemContext} from "../../vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol"; + +ISystemContext constant SYSTEM_CONTEXT_CONTRACT = ISystemContext(address(0x800b)); + +/** + * @title CallWithExactGasZKSync + * @notice Library that attempts to call a target contract with exactly `gasAmount` gas on zkSync + * and measures how much gas was actually used. + * Implementation based on the GasBoundCaller contract, https://github.com/matter-labs/era-contracts/blob/main/gas-bound-caller/contracts/GasBoundCaller.sol + */ +library CallWithExactGasZKSync { + error NoContract(); + error NotEnoughGasForPubdata(); + /// @notice We assume that no more than `CALL_RETURN_OVERHEAD` ergs are used for the O(1) operations at the end of the execution, + /// as such relaying the return. + uint256 internal constant CALL_RETURN_OVERHEAD = 400; + + bytes4 internal constant NO_CONTRACT_SIG = 0x0c3b563c; + + /// @notice The function that implements limiting of the total gas expenditure of the call. + /// @dev On Era, the gas for pubdata is charged at the end of the execution of the entire transaction, meaning + /// that if a subcall is not trusted, it can consume lots of pubdata in the process. This function ensures that + /// no more than `_maxTotalGas` will be allowed to be spent by the call. To be sure, this function uses some margin + /// (`CALL_ENTRY_OVERHEAD` + `CALL_RETURN_OVERHEAD`) to ensure that the call will not exceed the limit, so it may + /// actually spend a bit less than `_maxTotalGas` in the end. + /// @dev The entire `gas` passed to this function could be used, regardless + /// of the `_maxTotalGas` parameter. In other words, `max(gas(), _maxTotalGas)` is the maximum amount of gas that can be spent by this function. + /// @dev The function relays the `returndata` returned by the callee. In case the `callee` reverts, it reverts with the same error. + /// @param _to The address of the contract to call. + /// @param _maxTotalGas the maximum amount of gas that can be spent by the call. + /// @param _data The calldata for the call. + /// @param _maxReturnBytes the maximum amount of bytes that can be returned by the call. + /// @return success whether the call succeeded + /// @return retData the return data from the call, capped at maxReturnBytes bytes + /// @return pubdataGasSpent the pubdata gas used. + function _callWithExactGasSafeReturnData( + address _to, + uint256 _maxTotalGas, + bytes memory _data, + uint16 _maxReturnBytes + ) internal returns (bool success, bytes memory, uint256 pubdataGasSpent) { + assembly { + // solidity calls check that a contract actually exists at the destination, so we do the same + // Note we do this check prior to measuring gas. + if iszero(extcodesize(_to)) { + mstore(0x0, NO_CONTRACT_SIG) + revert(0x0, 0x4) + } + } + + // We require that `_maxTotalGas` does not exceed the current `gasleft()`. + // This is a safety check to ensure that a gas limit higher than the available gas is not specified, + // which would indicate incorrect parameters and could lead to unexpected behavior. + if (_maxTotalGas > gasleft()) { + return (false, "", 0); + } + + uint256 pubdataPublishedBefore = SYSTEM_CONTEXT_CONTRACT.getCurrentPubdataSpent(); + + assembly { + // call and return whether we succeeded. ignore return data + // call(gas,addr,value,argsOffset,argsLength,retOffset,retLength) + success := call(_maxTotalGas, _to, 0, add(_data, 0x20), mload(_data), 0x0, 0x0) + } + bytes memory returnData = new bytes(_maxReturnBytes); + assembly { + // limit our copy to maxReturnBytes bytes + let toCopy := returndatasize() + if gt(toCopy, _maxReturnBytes) { + toCopy := _maxReturnBytes + } + // Store the length of the copied bytes + mstore(returnData, toCopy) + // copy the bytes from retData[0:_toCopy] + returndatacopy(add(returnData, 0x20), 0x0, toCopy) + } + + uint256 pubdataPublishedAfter = SYSTEM_CONTEXT_CONTRACT.getCurrentPubdataSpent(); + + // It is possible that pubdataPublishedAfter < pubdataPublishedBefore if the call, e.g. removes + // some of the previously created state diffs + uint256 pubdataSpent = pubdataPublishedAfter > pubdataPublishedBefore + ? pubdataPublishedAfter - pubdataPublishedBefore + : 0; + + uint256 pubdataGasRate = SYSTEM_CONTEXT_CONTRACT.gasPerPubdataByte(); + + // In case there is an overflow here, the `_maxTotalGas` wouldn't be able to cover it anyway, so + // we don't mind the contract panicking here in case of it. + pubdataGasSpent = pubdataGasRate * pubdataSpent; + if (pubdataGasSpent != 0) { + // Here we double check that the additional cost is not higher than the maximum allowed. + // Note, that the `gasleft()` can be spent on pubdata too. + if (gasleft() < pubdataGasSpent + CALL_RETURN_OVERHEAD) { + revert NotEnoughGasForPubdata(); + } + } + return (success, returnData, pubdataGasSpent); + } +} diff --git a/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSync.t.sol b/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSync.t.sol new file mode 100644 index 00000000000..8d4ba75d505 --- /dev/null +++ b/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSync.t.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {CallWithExactGasZKSync} from "../../call/CallWithExactGasZKSync.sol"; +import {CallWithExactGasZKSyncHelper} from "./CallWithExactGasZKSyncHelper.sol"; +import {BaseTest} from "../BaseTest.t.sol"; + +import {MockSystemContext} from "../mocks/MockSystemContext.sol"; +import {TestTarget} from "../testhelpers/TestTarget.sol"; + +contract CallWithExactGasZKSyncSetup is BaseTest { + CallWithExactGasZKSyncHelper internal s_helper; + MockSystemContext internal s_mockSystemContext; + TestTarget internal s_target; + + // Import the errors from the library (for vm.expectRevert checks) + error NoContract(); + error NotEnoughGasForPubdata(); + error NotEnoughGasForCall(); + + function setUp() public virtual override { + s_mockSystemContext = new MockSystemContext(); + // Write mock's code to 0x800b so library calls see it + vm.etch(address(0x800b), address(s_mockSystemContext).code); + + s_helper = new CallWithExactGasZKSyncHelper(); + s_target = new TestTarget(); + } + + function _limitedGasCallWithExactGas( + uint256 allowedGas, + address _to, + uint256 _maxTotalGas, + bytes memory _data, + uint16 _maxReturnBytes + ) internal returns (bool success, bytes memory retData) { + // Encode the call to the helper function: + bytes memory payload = abi.encodeWithSelector( + CallWithExactGasZKSyncHelper.callWithExactGasSafeReturnData.selector, + _to, + _maxTotalGas, + _data, + _maxReturnBytes + ); + + // Constrain the subcall to `allowedGas` + (success, retData) = address(s_helper).call{gas: allowedGas}(payload); + + return (success, retData); + } + + function _decodeResult( + bytes memory retData + ) internal pure returns (bool callSuccess, bytes memory callRetData, uint256 pubdataGasSpent) { + // The helper returns (bool, bytes, uint256) + return abi.decode(retData, (bool, bytes, uint256)); + } +} + +contract CallWithExactGasZKSync__callWithExactGasSafeReturnData is CallWithExactGasZKSyncSetup { + /// @notice Reverts if target has no code => "NoContract()" + function test__callWithExactGasSafeReturnData_RevertWhen_NoContract() public { + (bool successCall, bytes memory retData) = _limitedGasCallWithExactGas( + 2_000_000, + address(12345), // no code + 1_000_000, + abi.encodeWithSelector(TestTarget.returnData.selector), + 100 + ); + assertFalse(successCall, "Subcall itself must revert"); + + if (retData.length >= 4) { + bytes4 errSig; + assembly { + errSig := mload(add(retData, 32)) + } + require(errSig == NoContract.selector, "Unexpected revert error"); + } + } + + /// @notice Reverts if _maxTotalGas is greater than gasleft() + function test__callWithExactGasSafeReturnData_FailsWhen_NotEnoughGasForCall() public { + (bool successCall, bytes memory retData) = _limitedGasCallWithExactGas( + 500_000, // subcall has ~500k gas available + address(s_target), + 600_000, // _maxTotalGas exceeds available gas => triggers NotEnoughGasForCall + abi.encodeWithSelector(TestTarget.returnData.selector), + 100 + ); + assertTrue(successCall, "Subcall itself must not revert"); + (bool success, bytes memory returnedData, uint256 pubdata) = _decodeResult(retData); + + assertFalse(success, "Target call must fail"); + assertEq(pubdata, 0, "No extra pubdata usage expected"); + assertEq(returnedData.length, 0, "Should not return any data"); + } + + /// @notice Reverts if pubdata usage is too high => "NotEnoughGasForPubdata()" + function test__callWithExactGasSafeReturnData_RevertWhen_NotEnoughGasForPubdata() public { + // Simulate pubdata usage: + // Set the initial pubdata value to 1000, then (via a mock) the after-call value to 5000. + s_mockSystemContext.setCurrentPubdataSpent(1000); + vm.mockCall( + address(s_mockSystemContext), + abi.encodeWithSelector(s_mockSystemContext.getCurrentPubdataSpent.selector), + abi.encode(5000) + ); + + // This difference = 4000 pubdata * 10 gas/byte = 40,000 extra gas needed. + // We'll provide allowed gas = 200,000, and _maxTotalGas = 200k. + // With the overhead, the check should fail, triggering NotEnoughGasForPubdata. + vm.expectRevert(NotEnoughGasForPubdata.selector); + + (bool successCall, ) = _limitedGasCallWithExactGas( + 400_000, // subcall gas + address(s_target), + 200_000, // _maxTotalGas exactly 200k + abi.encodeWithSelector(TestTarget.returnData.selector), + 100 + ); + assertFalse(successCall, "Subcall itself must revert"); + } + + /// @notice Succeeds under normal conditions, returning data. + function test__callWithExactGasSafeReturnData_Success() public { + (bool successCall, bytes memory retData) = _limitedGasCallWithExactGas( + 5_000_000, + address(s_target), + 4_000_000, + abi.encodeWithSelector(TestTarget.returnData.selector), + 10000 + ); + assertTrue(successCall, "Subcall itself must not revert"); + (bool success, bytes memory returnedData, uint256 pubdata) = _decodeResult(retData); + + assertTrue(success, "Target call must succeed"); + assertEq(pubdata, 0, "No extra pubdata usage expected"); + assertNotEq(returnedData.length, 0, "Should have returned some data"); + assertEq(abi.decode(returnedData, (string)), "Hello from TestTarget"); + } + + /// @notice Truncates return data if it exceeds _maxReturnBytes. + function test__callWithExactGasSafeReturnData_TruncatesData() public { + (bool successCall, bytes memory retData) = _limitedGasCallWithExactGas( + 500_000, + address(s_target), + 300_000, + abi.encodeWithSelector(TestTarget.returnLargeData.selector), + 50 // only allow 50 bytes of return data + ); + + assertTrue(successCall, "Subcall must not revert"); + (bool success, bytes memory returnedData, ) = _decodeResult(retData); + assertTrue(success, "Target call must succeed"); + assertEq(returnedData.length, 50, "Should have truncated the large data to 50 bytes"); + } + + /// @notice Reverts with a revert reason when the target reverts with reason. + function test__callWithExactGasSafeReturnData_RevertWhen_TargetRevertsWithReason() public { + // Expect the revert reason "CustomRevertReason" + vm.expectRevert(bytes("CustomRevertReason")); + + (bool successCall, ) = _limitedGasCallWithExactGas( + 1_000_000, + address(s_target), + 1_000_000, + abi.encodeWithSelector(TestTarget.revertWithReason.selector), + 100 + ); + assertFalse(successCall, "Subcall itself must revert"); + } + + /// @notice Reverts if the target reverts without a reason. + function test__callWithExactGasSafeReturnData_RevertWhen_TargetRevertsNoReason() public { + vm.expectRevert(); // just expect some revert, no reason + _limitedGasCallWithExactGas( + 1_000_000, + address(s_target), + 1_000_000, + abi.encodeWithSelector(TestTarget.revertNoReason.selector), + 100 + ); + } +} diff --git a/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSyncHelper.sol b/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSyncHelper.sol new file mode 100644 index 00000000000..eff72232386 --- /dev/null +++ b/contracts/src/v0.8/shared/test/call/CallWithExactGasZKSyncHelper.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {CallWithExactGasZKSync} from "../../call/CallWithExactGasZKSync.sol"; + +/** + * @notice This helper contract exposes the `_callWithExactGasSafeReturnData` function from the + * CallWithExactGasZKSync library so it can be called easily in unit tests. + */ +contract CallWithExactGasZKSyncHelper { + function callWithExactGasSafeReturnData( + address _to, + uint256 _maxTotalGas, + bytes memory _data, + uint16 _maxReturnBytes + ) external returns (bool success, bytes memory retData, uint256 pubdataGasSpent) { + return CallWithExactGasZKSync._callWithExactGasSafeReturnData(_to, _maxTotalGas, _data, _maxReturnBytes); + } +} diff --git a/contracts/src/v0.8/shared/test/mocks/MockSystemContext.sol b/contracts/src/v0.8/shared/test/mocks/MockSystemContext.sol new file mode 100644 index 00000000000..16af8ccffcd --- /dev/null +++ b/contracts/src/v0.8/shared/test/mocks/MockSystemContext.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ISystemContext} from "../../../vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol"; + +/// +/// @notice A minimal mock for ISystemContext to satisfy all interface functions. +/// This can be deployed (not abstract) so you can reference it in tests. +/// +contract MockSystemContext is ISystemContext { + // --------------------------------------- + // Storage variables for testing + // --------------------------------------- + uint256 private s_currentPubdataSpent; + uint256 private s_gasPerPubdataByte = 10; + + // Example placeholders for block number & timestamp + uint128 private s_mockBlockNumber = 1000; + uint128 private s_mockBlockTimestamp = 123456789; + + // --------------------------------------- + // Functions required by ISystemContext + // --------------------------------------- + + function chainId() external view override returns (uint256) { + // Return the current chain ID or a fixed mock + return block.chainid; + } + + function origin() external view override returns (address) { + // Return the tx.origin or a mock address + // solhint-disable-next-line avoid-tx-origin + return tx.origin; + } + + function gasPrice() external pure override returns (uint256) { + // Return a dummy gas price + return 1000000000; // 1 gwei, for example + } + + function blockGasLimit() external pure override returns (uint256) { + // Return a dummy block gas limit + return 30_000_000; + } + + function coinbase() external view override returns (address) { + // Return the current block.coinbase or a mock + return block.coinbase; + } + + function difficulty() external view override returns (uint256) { + // Return the block.difficulty or a fixed mock + return block.prevrandao; + } + + function baseFee() external view override returns (uint256) { + // Return the current block.basefee or a mock + return block.basefee; + } + + function txNumberInBlock() external pure override returns (uint16) { + // Return a dummy txNumberInBlock + return 1; + } + + function getBlockHashEVM(uint256 _block) external view override returns (bytes32) { + // Return a dummy value (or actual blockhash if you prefer) + return blockhash(_block); + } + + function getBatchHash(uint256 _batchNumber) external pure override returns (bytes32) { + // Return dummy + return keccak256(abi.encodePacked("BatchHashMock", _batchNumber)); + } + + function getBlockNumber() external view override returns (uint128) { + // Return your stored mock or real block.number cast to uint128 + return s_mockBlockNumber; + } + + function getBlockTimestamp() external view override returns (uint128) { + // Return your stored mock or real block.timestamp cast to uint128 + return s_mockBlockTimestamp; + } + + function getBatchNumberAndTimestamp() external view override returns (uint128 blockNumber, uint128 blockTimestamp) { + // Return dummy or relevant block info + return (s_mockBlockNumber, s_mockBlockTimestamp); + } + + function getL2BlockNumberAndTimestamp() external view override returns (uint128 blockNumber, uint128 blockTimestamp) { + // Return dummy or relevant block info + return (s_mockBlockNumber, s_mockBlockTimestamp); + } + + function gasPerPubdataByte() external pure override returns (uint256 gasPerPubdataByte_) { + return gasPerPubdataByte_; + } + + function getCurrentPubdataSpent() external pure override returns (uint256 currentPubdataSpent) { + return currentPubdataSpent; + } + + // --------------------------------------- + // Extra helpers for testing + // --------------------------------------- + + /// @notice Lets you set the mock pubdata spent for testing + function setCurrentPubdataSpent(uint256 newVal) external { + s_currentPubdataSpent = newVal; + } + + /// @notice Lets you set the mock gas per pubdata byte for testing + function setGasPerPubdataByte(uint256 newVal) external { + s_gasPerPubdataByte = newVal; + } + + /// @notice Lets you set the mock block number + function setMockBlockNumber(uint128 newVal) external { + s_mockBlockNumber = newVal; + } + + /// @notice Lets you set the mock block timestamp + function setMockBlockTimestamp(uint128 newVal) external { + s_mockBlockTimestamp = newVal; + } +} diff --git a/contracts/src/v0.8/shared/test/testhelpers/TestTarget.sol b/contracts/src/v0.8/shared/test/testhelpers/TestTarget.sol new file mode 100644 index 00000000000..74277d9f22c --- /dev/null +++ b/contracts/src/v0.8/shared/test/testhelpers/TestTarget.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +/// +/// @notice A simple target contract to demonstrate success/failure paths. +/// +contract TestTarget { + error CustomRevertReason(); + + // Returns some small data + function returnData() external pure returns (string memory) { + return "Hello from TestTarget"; + } + + // Returns ~200 bytes + function returnLargeData() external pure returns (bytes memory) { + bytes memory out = new bytes(200); + for (uint256 i = 0; i < 200; i++) { + out[i] = bytes1(uint8(65 + (i % 26))); // A..Z + } + return out; + } + + // Reverts with a custom reason + function revertWithReason() external pure { + revert CustomRevertReason(); + } + + // Reverts with no reason + function revertNoReason() external pure { + // solhint-disable-next-line reason-string, gas-custom-errors + revert(); + } +} diff --git a/contracts/src/v0.8/vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol b/contracts/src/v0.8/vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol new file mode 100644 index 00000000000..8b50cb3c5e1 --- /dev/null +++ b/contracts/src/v0.8/vendor/@matter-labs/era-contracts/gas-bound-caller/contracts/ISystemContext.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT + +pragma solidity ^0.8.19; + +/** + * @author Matter Labs + * @custom:security-contact security@matterlabs.dev + * @notice Contract that stores some of the context variables, that may be either + * block-scoped, tx-scoped or system-wide. + */ +interface ISystemContext { + struct BlockInfo { + uint128 timestamp; + uint128 number; + } + + /// @notice A structure representing the timeline for the upgrade from the batch numbers to the L2 block numbers. + /// @dev It will be used for the L1 batch -> L2 block migration in Q3 2023 only. + struct VirtualBlockUpgradeInfo { + /// @notice In order to maintain consistent results for `blockhash` requests, we'll + /// have to remember the number of the batch when the upgrade to the virtual blocks has been done. + /// The hashes for virtual blocks before the upgrade are identical to the hashes of the corresponding batches. + uint128 virtualBlockStartBatch; + /// @notice L2 block when the virtual blocks have caught up with the L2 blocks. Starting from this block, + /// all the information returned to users for block.timestamp/number, etc should be the information about the L2 blocks and + /// not virtual blocks. + uint128 virtualBlockFinishL2Block; + } + + function chainId() external view returns (uint256); + + function origin() external view returns (address); + + function gasPrice() external view returns (uint256); + + function blockGasLimit() external view returns (uint256); + + function coinbase() external view returns (address); + + function difficulty() external view returns (uint256); + + function baseFee() external view returns (uint256); + + function txNumberInBlock() external view returns (uint16); + + function getBlockHashEVM(uint256 _block) external view returns (bytes32); + + function getBatchHash(uint256 _batchNumber) external view returns (bytes32 hash); + + function getBlockNumber() external view returns (uint128); + + function getBlockTimestamp() external view returns (uint128); + + function getBatchNumberAndTimestamp() external view returns (uint128 blockNumber, uint128 blockTimestamp); + + function getL2BlockNumberAndTimestamp() external view returns (uint128 blockNumber, uint128 blockTimestamp); + + function gasPerPubdataByte() external view returns (uint256 gasPerPubdataByte); + + function getCurrentPubdataSpent() external view returns (uint256 currentPubdataSpent); +}