diff --git a/.gitignore b/.gitignore index 781249520f..0c2c04268b 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,6 @@ venv/ target/ dist/ .DS_Store + +# Yarn 4 local state (not for zero-installs) +.yarn/install-state.gz diff --git a/solidity/ecdsa/contracts/test/IMockTarget.sol b/solidity/ecdsa/contracts/test/IMockTarget.sol new file mode 100644 index 0000000000..204ed4c3e6 --- /dev/null +++ b/solidity/ecdsa/contracts/test/IMockTarget.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +/// @notice Test-only interface exercising the shapes `MockContract` has to +/// answer: a view function reached by STATICCALL, a state-changing +/// function whose calls are asserted on, a function returning nothing, +/// a struct return and a multi-value return. +interface IMockTarget { + struct Info { + address owner; + uint64 createdAt; + bool active; + } + + function doThing(address who, uint256 amount) external returns (bool); + + function noReturn(uint256 value) external; + + function readValue(uint256 key) external view returns (uint256); + + function readInfo(uint256 key) external view returns (Info memory); + + function readPair(uint256 key) external view returns (uint32, uint32); +} diff --git a/solidity/ecdsa/contracts/test/MockContract.sol b/solidity/ecdsa/contracts/test/MockContract.sol new file mode 100644 index 0000000000..97bf06c1f9 --- /dev/null +++ b/solidity/ecdsa/contracts/test/MockContract.sol @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +/// @notice Test-only programmable mock. Answers any call according to a table +/// set up from the test, and records the calls it receives. +/// +/// This is the contract half of the replacement for the archived +/// defi-wonderland smock package. smock worked by reaching into +/// Hardhat's provider internals, which is why it broke on Hardhat +/// >= 2.20 and why it cannot be carried forward. Everything here is +/// ordinary EVM state, so it survives any Hardhat, ethers, viem or +/// Foundry change. +/// +/// The model is deliberately Foundry's `vm.mockCall`: a +/// calldata-to-returndata table plus call recording. If the suite ever +/// moves to Solidity tests, the semantics carry over unchanged. +/// +/// Lookup order for an incoming call, first match wins: +/// 1. exact full-calldata match — smock's `whenCalledWith` +/// 2. selector match — smock's bare `returns` +/// 3. the response of last resort installed by the helper when the +/// mock was created: the zero value of the function's return +/// type, which is how smock answered an unstubbed function +/// +/// Reverts are configured the same way and take priority over returns +/// at the same specificity. +/// +/// @dev Administrative entry points are prefixed `__mock__` so they are +/// addressable alongside whatever interface is being mocked. A four-byte +/// collision between one of them and a real function of the mocked +/// interface would silently shadow that function, so the TypeScript helper +/// asserts no collision exists at construction time rather than leaving it +/// to chance. +contract MockContract { + // The `__mock__` prefix namespaces this contract's own entry points away + // from whatever interface it is answering, which is the point; mixedCase + // would defeat it. + // solhint-disable func-name-mixedcase + + enum Behaviour { + Unset, + Return, + Revert + } + + struct Response { + Behaviour behaviour; + bytes data; + } + + /// @dev All mock state lives behind one hashed base slot. + /// + /// Mocks are routinely installed over an address that already holds a + /// deployed contract — `test/fixtures/bridge.ts` pins them at the + /// Bridge's real `ecdsaWalletRegistry` and `relay` addresses, and 19 + /// call sites pass an explicit address. `hardhat_setCode` replaces the + /// code but leaves that contract's storage behind, so state at slots + /// 0, 1, 2... would be read back as configuration. An ERC-7201-style + /// base slot puts this contract's state where nothing else has been. + struct State { + /// @dev keccak256(full calldata) => response. + mapping(bytes32 => Response) responseByCalldata; + /// @dev selector => response. + mapping(bytes4 => Response) responseBySelector; + /// @dev selector => response of last resort, installed once when the + /// mock is created and never cleared by `reset`. + /// + /// Solidity checks returndatasize against the size its ABI expects + /// and reverts on a short answer, so an unconfigured function + /// cannot simply return nothing: it has to return a correctly + /// encoded zero. Only the helper knows the mocked ABI, so it + /// computes those encodings and installs them here. That + /// reproduces smock, where an unstubbed function yields the zero + /// value of its return type. + mapping(bytes4 => Response) baseResponseBySelector; + /// @dev Selectors that must never be recorded, because the mocked ABI + /// declares them `view` or `pure` and so they arrive by + /// STATICCALL. See `__mock__record`. + mapping(bytes4 => bool) nonRecording; + /// @dev Keys of every exact-calldata entry configured so far, with the + /// selector each belongs to. Exact-calldata entries are keyed by + /// hash and so cannot be enumerated from the mapping; `reset` + /// needs to clear them, and inferring them from recorded calls + /// would be wrong because view calls are never recorded. + bytes32[] configuredCalldataKeys; + mapping(bytes32 => bytes4) selectorOfCalldataKey; + mapping(bytes32 => bool) calldataKeyKnown; + /// @dev Selectors given a default response, for the same reason. + bytes4[] configuredSelectors; + mapping(bytes4 => bool) selectorKnown; + /// @dev Every call recorded, in order, as raw calldata. Kept whole + /// rather than decoded so the helper can decode against whichever + /// ABI the test declared. + bytes[] receivedCalls; + /// @dev msg.value of each recorded call, parallel to `receivedCalls`. + /// smock exposed this as `getCall(n).value`, and payable mocks + /// are asserted on it -- `transferTokensWithPayload` carries the + /// Wormhole message fee. + uint256[] receivedValues; + /// @dev Set to disable recording entirely. + /// + /// Recording costs real gas -- smock zeroed gas for faked calls, + /// this contract SSTOREs the calldata -- so a test measuring the + /// gas of the contract under test would otherwise be measuring + /// the mock's bookkeeping too. + bool recordingDisabled; + } + + /// @dev keccak256("tbtc.test.MockContract.state.v1") - 1, masked, per the + /// ERC-7201 convention. + /// @dev Gas handed to the recording self-call. + /// + /// An explicit cap matters more than the value. try/catch does not + /// recover from out-of-gas: an uncapped sub-call takes 63/64 of what + /// is left, so if recording runs out, the caller is left with too + /// little to continue and the whole transaction reverts. Capping it + /// means a failed recording costs this much and nothing more, which + /// keeps recording genuinely best-effort. Generous enough for any + /// realistic calldata. + uint256 private constant RECORD_GAS_STIPEND = 1_000_000; + + bytes32 private constant STATE_SLOT = + 0xdd9627cd601a6555f69239ac336fba8db95c419564b84ebb3ee2c21fed88ae00; + + function _state() private pure returns (State storage state) { + bytes32 slot = STATE_SLOT; + // solhint-disable-next-line no-inline-assembly + assembly { + state.slot := slot + } + } + + /// @notice Configures the response for one exact calldata payload. + /// @param callData Full ABI-encoded calldata, selector included. + /// @param returnData ABI-encoded return value. Empty for void functions. + function __mock__setReturnForCalldata( + bytes calldata callData, + bytes calldata returnData + ) external { + __mock__rememberCalldataKey(callData); + _state().responseByCalldata[keccak256(callData)] = Response( + Behaviour.Return, + returnData + ); + } + + /// @notice Configures the default response for a selector, used when no + /// exact-calldata entry matches. + function __mock__setReturnForSelector( + bytes4 selector, + bytes calldata returnData + ) external { + __mock__rememberSelector(selector); + _state().responseBySelector[selector] = Response( + Behaviour.Return, + returnData + ); + } + + /// @notice Makes one exact calldata payload revert. + /// @param revertData Raw revert payload. Empty reverts with no data. + function __mock__setRevertForCalldata( + bytes calldata callData, + bytes calldata revertData + ) external { + __mock__rememberCalldataKey(callData); + _state().responseByCalldata[keccak256(callData)] = Response( + Behaviour.Revert, + revertData + ); + } + + /// @notice Makes every call to a selector revert, unless an exact-calldata + /// entry matches first. + function __mock__setRevertForSelector( + bytes4 selector, + bytes calldata revertData + ) external { + __mock__rememberSelector(selector); + _state().responseBySelector[selector] = Response( + Behaviour.Revert, + revertData + ); + } + + /// @notice Clears every response configured for one selector and forgets + /// the calls recorded for it. This is smock's `fn.reset()`. + function __mock__resetSelector(bytes4 selector) external { + delete _state().responseBySelector[selector]; + + uint256 keptKeys = 0; + uint256 totalKeys = _state().configuredCalldataKeys.length; + for (uint256 i = 0; i < totalKeys; i++) { + bytes32 key = _state().configuredCalldataKeys[i]; + + if (_state().selectorOfCalldataKey[key] == selector) { + delete _state().responseByCalldata[key]; + delete _state().selectorOfCalldataKey[key]; + delete _state().calldataKeyKnown[key]; + } else { + _state().configuredCalldataKeys[keptKeys] = key; + keptKeys++; + } + } + while (_state().configuredCalldataKeys.length > keptKeys) { + _state().configuredCalldataKeys.pop(); + } + + uint256 keptCalls = 0; + uint256 totalCalls = _state().receivedCalls.length; + for (uint256 i = 0; i < totalCalls; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) != selector) { + _state().receivedCalls[keptCalls] = _state().receivedCalls[i]; + _state().receivedValues[keptCalls] = _state().receivedValues[i]; + keptCalls++; + } + } + while (_state().receivedCalls.length > keptCalls) { + _state().receivedCalls.pop(); + _state().receivedValues.pop(); + } + } + + /// @notice Clears every configured response and every recorded call. + function __mock__reset() external { + uint256 totalKeys = _state().configuredCalldataKeys.length; + for (uint256 i = 0; i < totalKeys; i++) { + bytes32 key = _state().configuredCalldataKeys[i]; + delete _state().responseByCalldata[key]; + delete _state().selectorOfCalldataKey[key]; + delete _state().calldataKeyKnown[key]; + } + delete _state().configuredCalldataKeys; + + uint256 totalSelectors = _state().configuredSelectors.length; + for (uint256 i = 0; i < totalSelectors; i++) { + delete _state().responseBySelector[_state().configuredSelectors[i]]; + delete _state().selectorKnown[_state().configuredSelectors[i]]; + } + delete _state().configuredSelectors; + + delete _state().receivedCalls; + delete _state().receivedValues; + } + + /// @notice Installs the responses of last resort. Called once by the + /// helper at construction with the zero value of every function's + /// return type. + function __mock__setBaseReturns( + bytes4[] calldata selectors, + bytes[] calldata returnData + ) external { + require( + selectors.length == returnData.length, + "MockContract: length mismatch" + ); + + for (uint256 i = 0; i < selectors.length; i++) { + _state().baseResponseBySelector[selectors[i]] = Response( + Behaviour.Return, + returnData[i] + ); + } + } + + /// @notice Turns call recording on or off for this mock. + function __mock__setRecording(bool enabled) external { + _state().recordingDisabled = !enabled; + } + + /// @notice Marks selectors that must never be recorded. The helper calls + /// this once with every `view` and `pure` function of the mocked + /// ABI. + function __mock__setNonRecordingSelectors(bytes4[] calldata selectors) + external + { + for (uint256 i = 0; i < selectors.length; i++) { + _state().nonRecording[selectors[i]] = true; + } + } + + /// @notice Whether a selector is excluded from recording. + function __mock__isNonRecording(bytes4 selector) + external + view + returns (bool) + { + return _state().nonRecording[selector]; + } + + /// @notice Clears the default response for one selector without touching + /// its exact-calldata entries or recorded calls. + function __mock__clearSelector(bytes4 selector) external { + delete _state().responseBySelector[selector]; + } + + /// @notice Appends a recorded call. Only ever invoked by this contract, on + /// itself, from `fallback`. + /// @dev Recording is a storage write, so it is impossible when the mocked + /// function was reached by STATICCALL — which is what Solidity emits + /// for a `view` or `pure` function on the mocked interface. Routing + /// the write through an external self-call lets `fallback` attempt it + /// and carry on when it fails, so a stubbed view function still + /// answers instead of reverting. + /// + /// The cost is that calls to view functions are not recorded, and so + /// cannot be asserted on. That is sound for this suite: every call + /// assertion in it targets a state-changing function, and a `view` + /// that a test wanted to count could not have been reached by + /// STATICCALL in the first place. + function __mock__record(bytes calldata callData, uint256 value) external { + require( + msg.sender == address(this), + "MockContract: recording is internal" + ); + _state().receivedCalls.push(callData); + _state().receivedValues.push(value); + } + + /// @notice Number of calls recorded, across all selectors. + function __mock__callCount() external view returns (uint256) { + return _state().receivedCalls.length; + } + + /// @notice Raw calldata of the i-th call recorded, across all selectors. + function __mock__callAt(uint256 index) + external + view + returns (bytes memory) + { + return _state().receivedCalls[index]; + } + + /// @notice Number of calls recorded for one selector. + function __mock__callCountForSelector(bytes4 selector) + external + view + returns (uint256 count) + { + uint256 total = _state().receivedCalls.length; + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + count++; + } + } + } + + /// @notice Raw calldata of the i-th call recorded for one selector. + /// @notice msg.value of the i-th call recorded for one selector. + function __mock__callValueForSelectorAt(bytes4 selector, uint256 index) + external + view + returns (uint256) + { + uint256 seen = 0; + uint256 total = _state().receivedCalls.length; + + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + if (seen == index) { + return _state().receivedValues[i]; + } + seen++; + } + } + + revert("MockContract: call index out of range"); + } + + function __mock__callForSelectorAt(bytes4 selector, uint256 index) + external + view + returns (bytes memory) + { + uint256 seen = 0; + uint256 total = _state().receivedCalls.length; + + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + if (seen == index) { + return _state().receivedCalls[i]; + } + seen++; + } + } + + revert("MockContract: call index out of range"); + } + + /// @dev Leading four bytes of `callData`, or zero if it is shorter. + function __mock__selectorOf(bytes memory callData) + public + pure + returns (bytes4 selector) + { + if (callData.length < 4) { + return bytes4(0); + } + + // solhint-disable-next-line no-inline-assembly + assembly { + selector := mload(add(callData, 32)) + } + } + + // A typed `fallback(bytes calldata) returns (bytes memory)` would read + // better, but prettier-plugin-solidity silently rewrites it to + // `fallback() external payable`, dropping the parameter and the return + // type — a formatter quietly changing semantics. Reading msg.data and + // returning through assembly is immune to that. + // solhint-disable-next-line no-complex-fallback + fallback() external payable { + // A `view` or `pure` function on the mocked ABI arrives by STATICCALL, + // where the storage write recording needs is impossible. Those + // selectors are flagged up front so the attempt is skipped outright + // rather than made and swallowed — which keeps them off the gas budget + // of the hot path, SPV proofs being the case that matters. + // + // The try/catch still guards the rest: a state-changing function is + // also reached statically under `eth_call`/`callStatic`. + if ( + !_state().recordingDisabled && + !_state().nonRecording[__mock__selectorOf(msg.data)] + ) { + // solhint-disable-next-line no-empty-blocks + try + this.__mock__record{gas: RECORD_GAS_STIPEND}( + msg.data, + msg.value + ) + {} catch {} + } + + bytes memory result = __mock__responseFor(msg.data); + + // solhint-disable-next-line no-inline-assembly + assembly { + return(add(result, 32), mload(result)) + } + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable {} + + /// @dev Resolves an incoming call against the three layers, most specific + /// first. Reverts here if the matched response is a configured revert. + function __mock__responseFor(bytes memory callData) + private + view + returns (bytes memory) + { + Response storage exact = _state().responseByCalldata[ + keccak256(callData) + ]; + if (exact.behaviour != Behaviour.Unset) { + return __mock__respond(exact); + } + + bytes4 selector = __mock__selectorOf(callData); + + Response storage bySelector = _state().responseBySelector[selector]; + if (bySelector.behaviour != Behaviour.Unset) { + return __mock__respond(bySelector); + } + + Response storage base = _state().baseResponseBySelector[selector]; + if (base.behaviour != Behaviour.Unset) { + return __mock__respond(base); + } + + // Last resort: a block of zeroes long enough to decode as the zero + // value of essentially any return shape. + // + // Empty returndata will not do. Solidity compares returndatasize + // against the size its ABI expects and reverts the caller on a short + // answer, and the helper's per-function zeroes only cover the ABI it + // was given. Production code reaches functions outside that ABI -- + // `AbstractBTCDepositor` calls `bridge.depositParameters()` through its + // own view of the Bridge -- and smock answered those too, with exactly + // this: a fixed run of zero bytes. + return new bytes(2048); + } + + function __mock__rememberSelector(bytes4 selector) private { + if (!_state().selectorKnown[selector]) { + _state().selectorKnown[selector] = true; + _state().configuredSelectors.push(selector); + } + } + + function __mock__rememberCalldataKey(bytes calldata callData) private { + bytes32 key = keccak256(callData); + + if (!_state().calldataKeyKnown[key]) { + _state().calldataKeyKnown[key] = true; + _state().selectorOfCalldataKey[key] = __mock__selectorOf(callData); + _state().configuredCalldataKeys.push(key); + } + } + + function __mock__respond(Response storage response) + private + view + returns (bytes memory) + { + if (response.behaviour == Behaviour.Revert) { + bytes memory revertData = response.data; + + // solhint-disable-next-line no-inline-assembly + assembly { + revert(add(revertData, 32), mload(revertData)) + } + } + + return response.data; + } +} diff --git a/solidity/ecdsa/contracts/test/MockTargetConsumer.sol b/solidity/ecdsa/contracts/test/MockTargetConsumer.sol new file mode 100644 index 0000000000..45c30efae4 --- /dev/null +++ b/solidity/ecdsa/contracts/test/MockTargetConsumer.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +import "./IMockTarget.sol"; + +/// @notice Test-only contract under test. It reaches the mocked interface the +/// way production code does — in particular, `view` functions are +/// reached by STATICCALL, which is the case a recording mock has to +/// survive without reverting. +contract MockTargetConsumer { + IMockTarget public immutable target; + + uint256 public lastValue; + bool public lastResult; + + constructor(IMockTarget _target) { + target = _target; + } + + /// @dev STATICCALL inside a state-changing function. + function cacheValue(uint256 key) external { + lastValue = target.readValue(key); + } + + /// @dev CALL: recorded by the mock and asserted on by the test. + function doThing(address who, uint256 amount) external { + lastResult = target.doThing(who, amount); + } + + function noReturn(uint256 value) external { + target.noReturn(value); + } + + /// @dev STATICCALL: `readValue` is `view` on the interface. + function readValueThroughStaticCall(uint256 key) + external + view + returns (uint256) + { + return target.readValue(key); + } + + function readInfoThroughStaticCall(uint256 key) + external + view + returns (IMockTarget.Info memory) + { + return target.readInfo(key); + } + + function readPairThroughStaticCall(uint256 key) + external + view + returns (uint32, uint32) + { + return target.readPair(key); + } +} diff --git a/solidity/ecdsa/hardhat.config.ts b/solidity/ecdsa/hardhat.config.ts index 3b04fbd5d7..8038e26de3 100644 --- a/solidity/ecdsa/hardhat.config.ts +++ b/solidity/ecdsa/hardhat.config.ts @@ -118,6 +118,13 @@ const config: HardhatUserConfig = { }, networks: { hardhat: { + // Configuring a `MockContract` is a transaction, and a transaction mines + // a block. Without this, Hardhat forces every block to be at least one + // second after its parent, so setting up a mock silently advances the + // chain clock and any test asserting on a deadline boundary inverts. + // `test/helpers/mock.ts` pins the next block's timestamp before each + // write; this option is what lets it reuse the current one. + allowBlocksWithSameTimestamp: true, forking: { // forking is enabled only if FORKING_URL env is provided enabled: !!process.env.FORKING_URL, diff --git a/solidity/ecdsa/package.json b/solidity/ecdsa/package.json index 59c83c9887..7406274bcd 100644 --- a/solidity/ecdsa/package.json +++ b/solidity/ecdsa/package.json @@ -36,7 +36,6 @@ "prepublishOnly": "hardhat prepare-artifacts --network $npm_config_network" }, "devDependencies": { - "@defi-wonderland/smock": "2.3.4", "@keep-network/hardhat-helpers": "^0.6.0-pre.15", "@keep-network/hardhat-local-networks-config": "^0.1.0-pre.4", "@nomicfoundation/hardhat-chai-matchers": "^1.0.6", diff --git a/solidity/ecdsa/test/Allowlist.test.ts b/solidity/ecdsa/test/Allowlist.test.ts index 0ae7744ae2..10ac085ab5 100644 --- a/solidity/ecdsa/test/Allowlist.test.ts +++ b/solidity/ecdsa/test/Allowlist.test.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-expressions, no-restricted-syntax, no-await-in-loop */ import { ethers, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" import { expect } from "chai" -import type { FakeContract } from "@defi-wonderland/smock" +import { createMock, expectCalledWith } from "./helpers/mock" + +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { Allowlist, WalletRegistry } from "../typechain" @@ -13,7 +14,7 @@ const ZERO_ADDRESS = ethers.constants.AddressZero describe("Allowlist", () => { let allowlist: Allowlist - let walletRegistry: FakeContract + let walletRegistry: Mock let governance: SignerWithAddress let stakingProvider1: SignerWithAddress let stakingProvider2: SignerWithAddress @@ -29,7 +30,7 @@ describe("Allowlist", () => { beforeEach(async () => { // Create fake WalletRegistry - walletRegistry = await smock.fake("WalletRegistry") + walletRegistry = await createMock("WalletRegistry") const [deployer, sp1, sp2, tp] = await ethers.getSigners() @@ -126,11 +127,11 @@ describe("Allowlist", () => { .connect(governance) .addStakingProvider(stakingProvider2.address, weight) - expect(walletRegistry.authorizationIncreased).to.have.been.calledWith( + await expectCalledWith(walletRegistry.authorizationIncreased, [ stakingProvider2.address, 0, - weight - ) + weight, + ]) }) it("should revert if staking provider already exists", async () => { @@ -213,13 +214,11 @@ describe("Allowlist", () => { .connect(governance) .requestWeightDecrease(stakingProvider1.address, newWeight) - expect( - walletRegistry.authorizationDecreaseRequested - ).to.have.been.calledWith( + await expectCalledWith(walletRegistry.authorizationDecreaseRequested, [ stakingProvider1.address, initialWeight, - newWeight - ) + newWeight, + ]) }) it("should allow setting weight to zero", async () => { diff --git a/solidity/ecdsa/test/DKGValidator.test.ts b/solidity/ecdsa/test/DKGValidator.test.ts index f16f062b1f..b6522dd6b5 100644 --- a/solidity/ecdsa/test/DKGValidator.test.ts +++ b/solidity/ecdsa/test/DKGValidator.test.ts @@ -14,7 +14,7 @@ import { import ecdsaData from "./data/ecdsa" import type { IWalletOwner } from "../typechain/IWalletOwner" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { DkgResult } from "./utils/dkg" import type { Operator } from "./utils/operators" import type { @@ -49,7 +49,7 @@ describe("EcdsaDkgValidator", () => { let walletRegistry: WalletRegistry let sortitionPool: SortitionPool - let walletOwner: FakeContract + let walletOwner: Mock let validator: EcdsaDkgValidator before("load test fixture", async () => { diff --git a/solidity/ecdsa/test/WalletRegistry.Authorization.test.ts b/solidity/ecdsa/test/WalletRegistry.Authorization.test.ts index 59a75115d2..63447eb514 100644 --- a/solidity/ecdsa/test/WalletRegistry.Authorization.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Authorization.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { deployments, ethers, getUnnamedAccounts, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" import { expect } from "chai" +import { createMock } from "./helpers/mock" import { constants, params, @@ -12,7 +12,7 @@ import { import { legacyTokenStakingAt } from "./utils/operators" import type { IWalletOwner } from "../typechain/IWalletOwner" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { ContractTransaction } from "ethers" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { @@ -78,7 +78,7 @@ const MAX_UINT64 = ethers.BigNumber.from("18446744073709551615") // 2^64 - 1 /** * Sets up real TokenStaking authorization for a staking provider using actual * contract calls instead of smock.fake. This avoids a known smock issue where - * smock.fake({address: existingAddress}) corrupts EVM storage in a way that + * createMock({address: existingAddress}) corrupts EVM storage in a way that * evm_revert cannot restore, breaking subsequent test suites. * * @param t - T token contract for minting @@ -226,8 +226,8 @@ describe("WalletRegistry - Authorization", () => { let authorizer: SignerWithAddress let beneficiary: SignerWithAddress let thirdParty: SignerWithAddress - let walletOwner: FakeContract - let slasher: FakeContract + let walletOwner: Mock + let slasher: Mock const stakedAmount = to1e18(1000000) // 1M T let minimumAuthorization @@ -275,7 +275,7 @@ describe("WalletRegistry - Authorization", () => { // Initialize slasher - fake application capable of slashing the // staking provider. - slasher = await smock.fake("IApplication") + slasher = await createMock("IApplication") await legacyTokenStakingAt(staking, deployer).approveApplication(slasher.address) await legacyTokenStakingAt(staking, authorizer).increaseAuthorization( stakingProvider.address, @@ -3876,14 +3876,14 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { * Coverage: Tests the true branch of ternary operator in _currentAuthorizationSource() */ describe("Post-Upgrade Mode (Allowlist Authorization)", () => { - let allowlist: FakeContract + let allowlist: Mock before(async () => { await createSnapshot() // Setup: Create allowlist fake and initialize WalletRegistry (triggers upgrade) - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(minimumAuthorization) + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(minimumAuthorization) await walletRegistry.initializeV2(allowlist.address) // Setup: Deactivate chaosnet to allow operators to join sortition pool @@ -3913,7 +3913,10 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { stakingProvider.address ) expect(eligibleStake).to.equal(minimumAuthorization) - expect(allowlist.authorizedStake).to.have.been.called + // The assertion above already proves the allowlist branch ran: the value + // returned is the one this mock is configured with. A call-count check on + // `authorizedStake` is not available -- it is `view`, so Solidity reaches + // it by STATICCALL, where the mock cannot record anything. }) /** @@ -3924,7 +3927,10 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { it("should query Allowlist when operator joins sortition pool", async () => { await walletRegistry.connect(operator).joinSortitionPool() expect(await walletRegistry.isOperatorInPool(operator.address)).to.be.true - expect(allowlist.authorizedStake).to.have.been.called + // The assertion above already proves the allowlist branch ran: the value + // returned is the one this mock is configured with. A call-count check on + // `authorizedStake` is not available -- it is `view`, so Solidity reaches + // it by STATICCALL, where the mock cannot record anything. }) /** @@ -3936,7 +3942,10 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { await joinPoolIfNotMember(walletRegistry, operator) expect(await walletRegistry.isOperatorUpToDate(operator.address)).to.be .true - expect(allowlist.authorizedStake).to.have.been.called + // The assertion above already proves the allowlist branch ran: the value + // returned is the one this mock is configured with. A call-count check on + // `authorizedStake` is not available -- it is `view`, so Solidity reaches + // it by STATICCALL, where the mock cannot record anything. }) /** @@ -3947,7 +3956,14 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { it("should query Allowlist when updating operator status", async () => { await joinPoolIfNotMember(walletRegistry, operator) await walletRegistry.updateOperatorStatus(operator.address) - expect(allowlist.authorizedStake).to.have.been.called + + // `authorizedStake` is `view`, so a call-count assertion is impossible -- + // Solidity reaches it by STATICCALL and the mock cannot record there. + // This is the stronger check anyway: the operator is only up to date if + // the registry read the allowlist's stake and synced the pool to it, so + // it fails if the allowlist branch is skipped. + expect(await walletRegistry.isOperatorUpToDate(operator.address)).to.be + .true }) /** @@ -3979,7 +3995,7 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { * - challengeDkgResult: Stake custody and slashing remain in TokenStaking (WalletRegistry.sol:950-966) */ describe.skip("NOT MIGRATED Touchpoints", () => { - let allowlist: FakeContract + let allowlist: Mock before(async () => { await createSnapshot() @@ -3996,8 +4012,8 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { ) // Setup: Create allowlist fake and upgrade (but beneficiary still in TokenStaking) - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(minimumAuthorization) + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(minimumAuthorization) await walletRegistry.initializeV2(allowlist.address) // Setup: Trigger authorization callback from allowlist (post-upgrade) @@ -4045,7 +4061,7 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { * Coverage: Tests upgrade transition and operator continuity */ describe.skip("Upgrade Flow", () => { - let allowlist: FakeContract + let allowlist: Mock before(async () => { await createSnapshot() @@ -4090,9 +4106,9 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { expect(preUpgradeStake).to.equal(minimumAuthorization) // Perform upgrade - allowlist = await smock.fake("IStaking") + allowlist = await createMock("IStaking") const upgradedAmount = to1e18(50000) // 50k T (different from TokenStaking) - allowlist.authorizedStake.returns(upgradedAmount) + await allowlist.authorizedStake.returns(upgradedAmount) await walletRegistry.initializeV2(allowlist.address) @@ -4117,8 +4133,8 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { expect(await walletRegistry.isOperatorInPool(operator.address)).to.be.true // Perform upgrade - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(minimumAuthorization) + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(minimumAuthorization) await walletRegistry.initializeV2(allowlist.address) // Operator still in pool and functional @@ -4140,8 +4156,8 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { await createSnapshot() // Setup: Allowlist with zero authorization (excludes operator) - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(0) // Zero weight = excluded + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(0) // Zero weight = excluded await walletRegistry.initializeV2(allowlist.address) // Operator excluded (eligible stake = 0) @@ -4163,7 +4179,7 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { * Expected: Proper validation and revert behavior. */ describe("Edge Cases", () => { - let allowlist: FakeContract + let allowlist: Mock before(async () => { await createSnapshot() @@ -4191,12 +4207,12 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { await createSnapshot() // First call succeeds - allowlist = await smock.fake("IStaking") + allowlist = await createMock("IStaking") await walletRegistry.initializeV2(allowlist.address) expect(await walletRegistry.allowlist()).to.equal(allowlist.address) // Second call fails - const allowlist2 = await smock.fake("IStaking") + const allowlist2 = await createMock("IStaking") await expect( walletRegistry.initializeV2(allowlist2.address) ).to.be.revertedWith("Initializable: contract is already initialized") @@ -4214,8 +4230,8 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { it("should persist allowlist address across multiple operations", async () => { await createSnapshot() - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(minimumAuthorization) + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(minimumAuthorization) await walletRegistry.initializeV2(allowlist.address) const addressAfterInit = await walletRegistry.allowlist() @@ -4249,16 +4265,17 @@ describe("WalletRegistry - Migration Scenario Tests (TIP-092)", () => { expect(stakeBefore).to.be.gte(0) // Validates staking branch executed // Branch 2: allowlist != address(0) → returns allowlist - allowlist = await smock.fake("IStaking") - allowlist.authorizedStake.returns(minimumAuthorization) + allowlist = await createMock("IStaking") + await allowlist.authorizedStake.returns(minimumAuthorization) await walletRegistry.initializeV2(allowlist.address) expect(await walletRegistry.allowlist()).to.equal(allowlist.address) const stakeAfter = await walletRegistry.eligibleStake( stakingProvider.address ) + // `stakeAfter` being the allowlist's configured value *is* the proof that + // the allowlist branch executed -- the staking branch would not return it. expect(stakeAfter).to.equal(minimumAuthorization) - expect(allowlist.authorizedStake).to.have.been.called // Validates allowlist branch executed await restoreSnapshot() }) diff --git a/solidity/ecdsa/test/WalletRegistry.CustomErrors.test.ts b/solidity/ecdsa/test/WalletRegistry.CustomErrors.test.ts index be68b558f5..f834ba8723 100644 --- a/solidity/ecdsa/test/WalletRegistry.CustomErrors.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.CustomErrors.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { deployments, ethers, getUnnamedAccounts, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" import { expect } from "chai" +import { createMock } from "./helpers/mock" import { constants, params, @@ -14,7 +14,7 @@ import { import type { IWalletOwner } from "../typechain/IWalletOwner" import type { IRandomBeacon } from "../typechain/IRandomBeacon" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { WalletRegistry, @@ -74,8 +74,8 @@ describe("WalletRegistry - Custom Errors", () => { let operator: SignerWithAddress let authorizer: SignerWithAddress let beneficiary: SignerWithAddress - let walletOwner: FakeContract - let randomBeacon: FakeContract + let walletOwner: Mock + let randomBeacon: Mock const stakedAmount = to1e18(1000000) // 1M T let minimumAuthorization @@ -123,7 +123,7 @@ describe("WalletRegistry - Custom Errors", () => { .registerOperator(operator.address) // Mock random beacon - randomBeacon = await smock.fake("IRandomBeacon") + randomBeacon = await createMock("IRandomBeacon") }) describe("Authorization Errors", () => { diff --git a/solidity/ecdsa/test/WalletRegistry.DualMode.test.ts b/solidity/ecdsa/test/WalletRegistry.DualMode.test.ts index 4a75062db1..ad9ab85913 100644 --- a/solidity/ecdsa/test/WalletRegistry.DualMode.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.DualMode.test.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { ethers, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" import { expect } from "chai" -import type { FakeContract } from "@defi-wonderland/smock" +import { createMock } from "./helpers/mock" + +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { WalletRegistry, Allowlist, IStaking } from "../typechain" @@ -13,8 +14,8 @@ const ZERO_ADDRESS = ethers.constants.AddressZero describe("WalletRegistry - Dual-Mode Authorization", () => { let walletRegistry: WalletRegistry - let allowlist: FakeContract - let stakingContract: FakeContract + let allowlist: Mock + let stakingContract: Mock let deployer: SignerWithAddress let governance: SignerWithAddress let stakingProvider: SignerWithAddress @@ -42,14 +43,14 @@ describe("WalletRegistry - Dual-Mode Authorization", () => { await ecdsaInactivity.deployed() // Create fake contracts first - allowlist = await smock.fake("Allowlist") - stakingContract = await smock.fake("IStaking") + allowlist = await createMock("Allowlist") + stakingContract = await createMock("IStaking") // Deploy fake dependencies for initialize - const sortitionPool = await smock.fake("SortitionPool") - const dkgValidator = await smock.fake("EcdsaDkgValidator") - const randomBeacon = await smock.fake("IRandomBeacon") - const reimbursementPool = await smock.fake("ReimbursementPool") + const sortitionPool = await createMock("SortitionPool") + const dkgValidator = await createMock("EcdsaDkgValidator") + const randomBeacon = await createMock("IRandomBeacon") + const reimbursementPool = await createMock("ReimbursementPool") // Deploy WalletRegistry proxy using ERC1967Proxy directly instead of // @openzeppelin/hardhat-upgrades' upgrades.deployProxy(). Using @@ -472,7 +473,7 @@ describe("WalletRegistry - Dual-Mode Authorization", () => { await walletRegistry.initializeV2(allowlist.address) // Attempt to change allowlist should fail - const newAllowlist = await smock.fake("Allowlist") + const newAllowlist = await createMock("Allowlist") await expect( walletRegistry.initializeV2(newAllowlist.address) diff --git a/solidity/ecdsa/test/WalletRegistry.Inactivity.test.ts b/solidity/ecdsa/test/WalletRegistry.Inactivity.test.ts index 4980ecfa97..60af82ad2f 100644 --- a/solidity/ecdsa/test/WalletRegistry.Inactivity.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Inactivity.test.ts @@ -2,6 +2,7 @@ import { ethers, helpers } from "hardhat" import { expect } from "chai" +import { expectCalledWith, expectNotCalled } from "./helpers/mock" import ecdsaData from "./data/ecdsa" import { params, walletRegistryFixture } from "./fixtures" import { createNewWallet } from "./utils/wallets" @@ -9,7 +10,7 @@ import { signOperatorInactivityClaim } from "./utils/inactivity" import { assertGasUsed } from "./helpers/gas" import type { BigNumber, ContractTransaction } from "ethers" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { SortitionPool, @@ -25,8 +26,8 @@ const { provider } = ethers describe("WalletRegistry - Inactivity", () => { let walletRegistry: WalletRegistry let sortitionPool: SortitionPool - let randomBeacon: FakeContract - let walletOwner: FakeContract + let randomBeacon: Mock + let walletOwner: Mock let thirdParty: SignerWithAddress @@ -310,16 +311,12 @@ describe("WalletRegistry - Inactivity", () => { after(async () => { await restoreSnapshot() - walletOwner.__ecdsaWalletHeartbeatFailedCallback.reset() }) it("should notify the wallet owner", async () => { - await expect( - walletOwner.__ecdsaWalletHeartbeatFailedCallback - ).to.be.calledWith( - walletID, - walletPublicKeyX, - walletPublicKeyY + await expectCalledWith( + walletOwner.__ecdsaWalletHeartbeatFailedCallback, + [walletID, walletPublicKeyX, walletPublicKeyY] ) }) }) @@ -360,9 +357,9 @@ describe("WalletRegistry - Inactivity", () => { }) it("should not notify the wallet owner", async () => { - await expect( + await expectNotCalled( walletOwner.__ecdsaWalletHeartbeatFailedCallback - ).not.to.be.called + ) }) }) }) diff --git a/solidity/ecdsa/test/WalletRegistry.Parameters.test.ts b/solidity/ecdsa/test/WalletRegistry.Parameters.test.ts index 9e13880b22..fabbccda15 100644 --- a/solidity/ecdsa/test/WalletRegistry.Parameters.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Parameters.test.ts @@ -4,7 +4,7 @@ import { expect } from "chai" import { walletRegistryFixture } from "./fixtures" import type { IWalletOwner } from "../typechain/IWalletOwner" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { WalletRegistry, @@ -21,8 +21,8 @@ describe("WalletRegistry - Parameters", async () => { let deployer: SignerWithAddress let governance: SignerWithAddress - let walletOwner: FakeContract - let randomBeacon: FakeContract + let walletOwner: Mock + let randomBeacon: Mock let thirdParty: SignerWithAddress before("load test fixture", async () => { diff --git a/solidity/ecdsa/test/WalletRegistry.RandomBeacon.test.ts b/solidity/ecdsa/test/WalletRegistry.RandomBeacon.test.ts index 75dcd8c2b0..700b0b5230 100644 --- a/solidity/ecdsa/test/WalletRegistry.RandomBeacon.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.RandomBeacon.test.ts @@ -1,14 +1,13 @@ /* eslint-disable no-underscore-dangle */ import { ethers, helpers } from "hardhat" import { expect } from "chai" -import { smock } from "@defi-wonderland/smock" +import { expectCalledWith } from "./helpers/mock" import { dkgState, walletRegistryFixture } from "./fixtures" -import { resetMock } from "./utils/randomBeacon" import { upgradeRandomBeacon } from "./utils/governance" import type { IWalletOwner } from "../typechain/IWalletOwner" -import type { MockContract, FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { IRandomBeacon, @@ -23,8 +22,8 @@ const { createSnapshot, restoreSnapshot } = helpers.snapshot describe("WalletRegistry - Random Beacon", async () => { let walletRegistry: WalletRegistryStub & WalletRegistry - let randomBeaconFake: FakeContract - let walletOwner: FakeContract + let randomBeaconFake: Mock + let walletOwner: Mock let thirdParty: SignerWithAddress before("load test fixture", async () => { @@ -53,8 +52,6 @@ describe("WalletRegistry - Random Beacon", async () => { after(async () => { await restoreSnapshot() - - resetMock(randomBeaconFake) }) it("should revert", async () => { @@ -82,9 +79,9 @@ describe("WalletRegistry - Random Beacon", async () => { }) it("should call random beacon", async () => { - await expect(randomBeaconFake.requestRelayEntry).to.be.calledWith( - walletRegistry.address - ) + await expectCalledWith(randomBeaconFake.requestRelayEntry, [ + walletRegistry.address, + ]) }) }) }) @@ -199,7 +196,7 @@ describe("WalletRegistry - Random Beacon", async () => { // set in the `RandomBeacon`'s `Callback` library is enough to cover // `WalletRegistry.__beaconCallback` execution. context("when called as a callback from random beacon", async () => { - let randomBeaconMock: MockContract + let randomBeaconMock: RandomBeaconStub before(async () => { await createSnapshot() @@ -232,15 +229,15 @@ describe("WalletRegistry - Random Beacon", async () => { async function mockRandomBeacon( walletRegistry: WalletRegistry -): Promise> { - const randomBeaconFactory = await smock.mock( - "RandomBeaconStub" - ) - - const randomBeacon: MockContract = - await randomBeaconFactory.deploy() +): Promise { + // This was a `smock.mock`, but it only ever supplied `.address` — none of + // smock's storage-override surface (`setVariable`, `getVariable`) was used, + // so an ordinary factory deploy of the same stub is equivalent. + const randomBeacon = await ( + await ethers.getContractFactory("RandomBeaconStub") + ).deploy() await upgradeRandomBeacon(walletRegistry, randomBeacon.address) - return randomBeacon + return randomBeacon as RandomBeaconStub } diff --git a/solidity/ecdsa/test/WalletRegistry.Rewards.test.ts b/solidity/ecdsa/test/WalletRegistry.Rewards.test.ts index f7ce29d0dd..cb310bded1 100644 --- a/solidity/ecdsa/test/WalletRegistry.Rewards.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Rewards.test.ts @@ -7,7 +7,7 @@ import { createNewWallet } from "./utils/wallets" import { signOperatorInactivityClaim } from "./utils/inactivity" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { Operator, OperatorID } from "./utils/operators" import type { SortitionPool, @@ -48,8 +48,8 @@ describe("WalletRegistry - Rewards", () => { let staking: TokenStaking let walletRegistryGovernance: WalletRegistryGovernance let sortitionPool: SortitionPool - let randomBeacon: FakeContract - let walletOwner: FakeContract + let randomBeacon: Mock + let walletOwner: Mock let deployer: SignerWithAddress let governance: SignerWithAddress diff --git a/solidity/ecdsa/test/WalletRegistry.Slashing.test.ts b/solidity/ecdsa/test/WalletRegistry.Slashing.test.ts index 7e6634c06d..34d82952f9 100644 --- a/solidity/ecdsa/test/WalletRegistry.Slashing.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Slashing.test.ts @@ -13,7 +13,7 @@ import type { T, IRandomBeacon, } from "../typechain" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { Operator, OperatorID } from "./utils/operators" @@ -51,8 +51,8 @@ describe.skip("TokenStaking Integration (DEPRECATED TIP-092)", () => { describe("WalletRegistry - Slashing", () => { let walletRegistry: WalletRegistry - let randomBeacon: FakeContract - let walletOwner: FakeContract + let randomBeacon: Mock + let walletOwner: Mock let thirdParty: SignerWithAddress let staking: TokenStaking let tToken: T diff --git a/solidity/ecdsa/test/WalletRegistry.WalletCreation.test.ts b/solidity/ecdsa/test/WalletRegistry.WalletCreation.test.ts index b74ac9baf5..364bc2ef51 100644 --- a/solidity/ecdsa/test/WalletRegistry.WalletCreation.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.WalletCreation.test.ts @@ -31,7 +31,7 @@ import type { DkgChallenger, } from "../typechain" import type { DkgResult, DkgResultSubmittedEventArgs } from "./utils/dkg" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" const { to1e18 } = helpers.number const { mineBlocks, mineBlocksTo } = helpers.time @@ -92,8 +92,8 @@ describe("WalletRegistry - Wallet Creation", async () => { let walletRegistry: WalletRegistryStub & WalletRegistry let sortitionPool: SortitionPool let staking: TokenStaking - let randomBeacon: FakeContract - let walletOwner: FakeContract + let randomBeacon: Mock + let walletOwner: Mock let deployer: SignerWithAddress let thirdParty: SignerWithAddress @@ -112,6 +112,13 @@ describe("WalletRegistry - Wallet Creation", async () => { operators, staking, } = await walletRegistryFixture({ useAllowlist: true })) + + // This suite asserts on the gas `approveDkgResult` costs, and that path + // calls into the wallet owner. Recording a call SSTOREs its calldata -- + // ~175k here -- which smock did not have to pay for, so leaving it on + // measures the mock rather than the WalletRegistry. Nothing in this file + // inspects the wallet owner's calls. + await walletOwner.setRecording(false) }) describe("requestNewWallet", async () => { @@ -1705,8 +1712,15 @@ describe("WalletRegistry - Wallet Creation", async () => { ) }) - it("should use close to 274 000 gas", async () => { - await assertGasUsed(tx, 274_000) + it("should use close to 286 000 gas", async () => { + // Was 274 000 while the wallet owner was a smock fake, which + // cost nothing to call. It is a `MockContract` now, so the + // dispatch through its fallback is real work: ~12k, with call + // recording already switched off for this suite. The +/- 1000 + // still catches a WalletRegistry-side regression, it is just + // measured from a higher floor. In production the wallet owner + // is a real contract and costs more than either figure. + await assertGasUsed(tx, 286_000) }) }) diff --git a/solidity/ecdsa/test/WalletRegistry.WalletOwner.test.ts b/solidity/ecdsa/test/WalletRegistry.WalletOwner.test.ts index c180cb018e..09e67e164d 100644 --- a/solidity/ecdsa/test/WalletRegistry.WalletOwner.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.WalletOwner.test.ts @@ -2,6 +2,7 @@ import { ethers, helpers } from "hardhat" import { expect } from "chai" +import { expectCalledWith } from "./helpers/mock" import { params, walletRegistryFixture } from "./fixtures" import { submitRelayEntry } from "./utils/randomBeacon" import { signAndSubmitCorrectDkgResult } from "./utils/dkg" @@ -9,7 +10,7 @@ import ecdsaData from "./data/ecdsa" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { DkgResult } from "./utils/dkg" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { IWalletOwner, WalletRegistry, @@ -34,7 +35,7 @@ describe("WalletRegistry - Wallet Owner", async () => { const walletID: string = ethers.utils.keccak256(groupPublicKey) let walletRegistry: WalletRegistryStub & WalletRegistry - let walletOwner: FakeContract + let walletOwner: Mock before("load test fixture", async () => { // eslint-disable-next-line @typescript-eslint/no-extra-semi @@ -75,7 +76,7 @@ describe("WalletRegistry - Wallet Owner", async () => { before(async () => { await createSnapshot() - walletOwner.__ecdsaWalletCreatedCallback.reverts( + await walletOwner.__ecdsaWalletCreatedCallback.reverts( "wallet owner internal error" ) @@ -115,11 +116,11 @@ describe("WalletRegistry - Wallet Owner", async () => { it("should call wallet owner", async () => { await tx - await expect(walletOwner.__ecdsaWalletCreatedCallback).to.be.calledWith( + await expectCalledWith(walletOwner.__ecdsaWalletCreatedCallback, [ walletID, groupPublicKeyX, - groupPublicKeyY - ) + groupPublicKeyY, + ]) }) }) }) diff --git a/solidity/ecdsa/test/WalletRegistry.Wallets.test.ts b/solidity/ecdsa/test/WalletRegistry.Wallets.test.ts index 1693bae102..7847995171 100644 --- a/solidity/ecdsa/test/WalletRegistry.Wallets.test.ts +++ b/solidity/ecdsa/test/WalletRegistry.Wallets.test.ts @@ -17,7 +17,7 @@ import type { WalletRegistry, WalletRegistryStub, } from "../typechain" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" const { createSnapshot, restoreSnapshot } = helpers.snapshot @@ -53,8 +53,8 @@ const validTestData = [ describe("WalletRegistry - Wallets", async () => { let walletRegistry: WalletRegistryStub & WalletRegistry - let randomBeacon: FakeContract - let walletOwner: FakeContract + let randomBeacon: Mock + let walletOwner: Mock let thirdParty: SignerWithAddress before("load test fixture", async () => { diff --git a/solidity/ecdsa/test/fixtures/index.ts b/solidity/ecdsa/test/fixtures/index.ts index 2766fff805..fbfa78dd79 100644 --- a/solidity/ecdsa/test/fixtures/index.ts +++ b/solidity/ecdsa/test/fixtures/index.ts @@ -43,7 +43,8 @@ */ import { deployments, ethers, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" + +import { createMock } from "../helpers/mock" // eslint-disable-next-line import/no-cycle import { registerOperators } from "../utils/operators" @@ -63,7 +64,7 @@ import type { IRandomBeacon, Allowlist, } from "../../typechain" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "../helpers/mock" const { to1e18 } = helpers.number @@ -118,8 +119,8 @@ const createWalletRegistryFixture = (options: { useAllowlist: boolean }) => { sortitionPool: SortitionPool reimbursementPool: ReimbursementPool staking: TokenStaking - randomBeacon: FakeContract - walletOwner: FakeContract + randomBeacon: Mock + walletOwner: Mock deployer: SignerWithAddress governance: SignerWithAddress thirdParty: SignerWithAddress @@ -147,7 +148,7 @@ const createWalletRegistryFixture = (options: { useAllowlist: boolean }) => { const reimbursementPool: ReimbursementPool = await helpers.contracts.getContract("ReimbursementPool") - const randomBeacon: FakeContract = await fakeRandomBeacon( + const randomBeacon: Mock = await fakeRandomBeacon( walletRegistry ) @@ -193,8 +194,10 @@ const createWalletRegistryFixture = (options: { useAllowlist: boolean }) => { await fundReimbursementPool(deployer, reimbursementPool) // Mock Wallet Owner contract. - const walletOwner: FakeContract = - await initializeWalletOwner(walletRegistryGovernance, governance) + const walletOwner: Mock = await initializeWalletOwner( + walletRegistryGovernance, + governance + ) return { tToken, @@ -349,11 +352,12 @@ export async function updateWalletRegistryParams( export async function initializeWalletOwner( walletRegistryGovernance: WalletRegistryGovernance, governance: SignerWithAddress -): Promise> { +): Promise> { const { deployer } = await helpers.signers.getNamedSigners() - const walletOwner: FakeContract = - await smock.fake("IWalletOwner") + const walletOwner: Mock = await createMock( + "IWalletOwner" + ) await deployer.sendTransaction({ to: walletOwner.address, diff --git a/solidity/ecdsa/test/helpers/mock.test.ts b/solidity/ecdsa/test/helpers/mock.test.ts new file mode 100644 index 0000000000..8e3700fb72 --- /dev/null +++ b/solidity/ecdsa/test/helpers/mock.test.ts @@ -0,0 +1,301 @@ +import { ethers } from "hardhat" +import { expect } from "chai" + +import { createMock, expectCalledWith } from "./mock" + +import type { Mock } from "./mock" +import type { IMockTarget, MockTargetConsumer } from "../../typechain" + +describe("MockContract", () => { + let target: Mock + let consumer: MockTargetConsumer + + beforeEach(async () => { + target = await createMock("IMockTarget") + + const factory = await ethers.getContractFactory("MockTargetConsumer") + consumer = (await factory.deploy(target.address)) as MockTargetConsumer + await consumer.deployed() + }) + + describe("view functions reached by STATICCALL", () => { + // The mock records calls, which is a storage write, and a storage write is + // impossible under STATICCALL. If recording were unconditional every + // stubbed view function would revert, so this is the case that decides + // whether a contract-backed mock is viable at all. + + it("answers a stubbed view function", async () => { + await target.readValue.returns(42) + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(42) + }) + + it("answers a stubbed view function from a state-changing caller", async () => { + await target.readValue.returns(99) + + await consumer.cacheValue(7) + + expect(await consumer.lastValue()).to.equal(99) + }) + + it("answers with a struct", async () => { + const owner = ethers.Wallet.createRandom().address + await target.readInfo.returns({ + owner, + createdAt: 1234, + active: true, + }) + + const info = await consumer.readInfoThroughStaticCall(1) + + expect(info.owner).to.equal(owner) + expect(info.createdAt).to.equal(1234) + expect(info.active).to.equal(true) + }) + + it("answers with multiple values", async () => { + await target.readPair.returns([11, 22]) + + const [first, second] = await consumer.readPairThroughStaticCall(1) + + expect(first).to.equal(11) + expect(second).to.equal(22) + }) + + it("returns the zero value when unstubbed", async () => { + // smock answers an unconfigured function with the zero value of its + // return type rather than reverting. Empty returndata would not do: + // Solidity checks returndatasize against the size its ABI expects and + // reverts the caller on a short answer, so the helper installs a + // correctly encoded zero for every function up front. + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + }) + + describe("whenCalledWith", () => { + it("takes precedence over the selector-wide default", async () => { + await target.readValue.returns(1) + await target.readValue.whenCalledWith(7).returns(700) + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(700) + expect(await consumer.readValueThroughStaticCall(8)).to.equal(1) + }) + + it("falls through to the default for non-matching arguments", async () => { + await target.readValue.whenCalledWith(7).returns(700) + + expect(await consumer.readValueThroughStaticCall(8)).to.equal(0) + }) + }) + + describe("reverts", () => { + it("reverts every call to the function", async () => { + await target.doThing.reverts("nope") + + await expect( + consumer.doThing(ethers.constants.AddressZero, 1) + ).to.be.revertedWith("nope") + }) + + it("reverts only the matching arguments", async () => { + const who = ethers.Wallet.createRandom().address + await target.doThing.returns(true) + await target.doThing.whenCalledWith(who, 5).reverts("blocked") + + await expect(consumer.doThing(who, 5)).to.be.revertedWith("blocked") + await expect(consumer.doThing(who, 6)).to.not.be.reverted + }) + }) + + describe("call recording", () => { + it("records arguments of a state-changing call", async () => { + const who = ethers.Wallet.createRandom().address + await target.doThing.returns(true) + + await consumer.doThing(who, 123) + + expect(await target.doThing.callCount()).to.equal(1) + + const call = await target.doThing.getCall(0) + expect(call.args[0]).to.equal(who) + expect(call.args[1]).to.equal(123) + }) + + it("expectCalledWith matches any recorded call, not only a lone one", async () => { + await consumer.noReturn(5) + await consumer.noReturn(6) + + // `calledWith` says nothing about how many calls there were, so both + // recorded calls must satisfy it and a third value must not. + await expectCalledWith(target.noReturn, [5]) + await expectCalledWith(target.noReturn, [6]) + + let failed = false + try { + await expectCalledWith(target.noReturn, [7]) + } catch { + failed = true + } + expect(failed, "expected a non-matching argument to fail").to.equal(true) + }) + + it("records a function that returns nothing", async () => { + await consumer.noReturn(5) + await consumer.noReturn(6) + + expect(await target.noReturn.callCount()).to.equal(2) + expect((await target.noReturn.getCall(1)).args[0]).to.equal(6) + }) + + it("counts each function separately", async () => { + await target.doThing.returns(true) + + await consumer.doThing(ethers.constants.AddressZero, 1) + await consumer.noReturn(1) + + expect(await target.doThing.callCount()).to.equal(1) + expect(await target.noReturn.callCount()).to.equal(1) + }) + }) + + describe("reset", () => { + it("clears recorded calls and configured responses for one function", async () => { + await target.doThing.returns(true) + await consumer.doThing(ethers.constants.AddressZero, 1) + + await target.doThing.reset() + + expect(await target.doThing.callCount()).to.equal(0) + // The configured `true` is gone, so the call answers with empty data. + await consumer.doThing(ethers.constants.AddressZero, 1) + expect(await consumer.lastResult()).to.equal(false) + }) + + it("clears a whenCalledWith entry", async () => { + await target.readValue.whenCalledWith(7).returns(700) + await target.readValue.reset() + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + + it("clears a view function's configuration, which is never recorded", async () => { + // `reset` cannot infer entries from recorded calls, because STATICCALL + // calls are not recorded. It has to track what was configured. + await target.readValue.returns(5) + await consumer.readValueThroughStaticCall(7) + + await target.readValue.reset() + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + + it("leaves other functions alone", async () => { + await target.readValue.returns(5) + await target.doThing.returns(true) + + await target.doThing.reset() + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(5) + }) + + it("clears everything at the mock level", async () => { + await target.readValue.returns(5) + await consumer.noReturn(1) + + await target.reset() + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(0) + expect(await target.noReturn.callCount()).to.equal(0) + }) + }) + + describe("wallet", () => { + it("sends transactions from the mock's own address", async () => { + // smock's `fake.wallet`, used where production code checks + // `msg.sender == someContract`. + const factory = await ethers.getContractFactory("MockTargetConsumer") + const other = await factory.deploy(target.address) + await other.deployed() + + const tx = await other.connect(target.wallet).noReturn(1) + const receipt = await tx.wait() + + expect(receipt.from).to.equal(target.address) + }) + }) + + describe("address option", () => { + it("deploys at a requested address", async () => { + const address = ethers.utils.getAddress( + `0x${"ab".repeat(20)}`.toLowerCase() + ) + + const pinned = await createMock("IMockTarget", { address }) + + expect(pinned.address).to.equal(address) + await pinned.readValue.returns(7) + + const factory = await ethers.getContractFactory("MockTargetConsumer") + const pinnedConsumer = await factory.deploy(address) + await pinnedConsumer.deployed() + + expect(await pinnedConsumer.readValueThroughStaticCall(1)).to.equal(7) + }) + }) + + describe("storage left behind at a pinned address", () => { + it("is not read back as configuration", async () => { + // Mocks are routinely installed over an address that already holds a + // deployed contract — `test/fixtures/bridge.ts` pins them at the Bridge's + // real ecdsaWalletRegistry and relay. `hardhat_setCode` replaces the code + // and leaves the storage, so a mock keeping its state at slots 0, 1, 2... + // would read that leftover as its own. + const address = ethers.utils.getAddress(`0x${"cd".repeat(20)}`) + const garbage = + "0xdeadbeef00000000000000000000000000000000000000000000000000000001" + + await Promise.all( + Array.from({ length: 8 }, (_, slot) => + ethers.provider.send("hardhat_setStorageAt", [ + address, + ethers.utils.hexValue(slot), + garbage, + ]) + ) + ) + + const pinned = await createMock("IMockTarget", { address }) + + expect(await pinned.doThing.callCount()).to.equal(0) + + await pinned.readValue.returns(7) + + const factory = await ethers.getContractFactory("MockTargetConsumer") + const pinnedConsumer = await factory.deploy(address) + await pinnedConsumer.deployed() + + expect(await pinnedConsumer.readValueThroughStaticCall(1)).to.equal(7) + }) + }) + + describe("call assertions on read-only functions", () => { + it("are refused rather than silently answered zero", async () => { + // A view function is reached by STATICCALL and cannot be recorded. + // Answering "0 calls" would read as a passing assertion. + let message = "" + try { + await target.readValue.callCount() + } catch (error) { + message = (error as Error).message + } + + expect(message).to.contain("STATICCALL") + }) + + it("still allow the function to be stubbed", async () => { + await target.readValue.returns(3) + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(3) + }) + }) +}) diff --git a/solidity/ecdsa/test/helpers/mock.ts b/solidity/ecdsa/test/helpers/mock.ts new file mode 100644 index 0000000000..ec2e8d75eb --- /dev/null +++ b/solidity/ecdsa/test/helpers/mock.ts @@ -0,0 +1,684 @@ +/* + * The `__mock__*` names below are the administrative entry points of + * `MockContract.sol`, deliberately namespaced there so they cannot be confused + * with — or collide with — a function of the interface being mocked. They are + * dictated by the contract, so the dangling-underscore rule does not apply. + */ +/* eslint-disable no-underscore-dangle */ +import { ethers, artifacts } from "hardhat" +import { expect } from "chai" +import { BigNumber } from "ethers" + +import type { BigNumberish, Contract, Signer } from "ethers" +import type { FunctionFragment, Interface, ParamType } from "ethers/lib/utils" + +/** + * Programmable contract mock, replacing `@defi-wonderland/smock`. + * + * smock configured its fakes by mutating in-process JavaScript state inside + * Hardhat's EVM, which is why its API was synchronous — and also why it broke + * on Hardhat >= 2.20 and is archived upstream with no forward path. This + * replacement drives an ordinary deployed contract (`MockContract.sol`) + * over the public provider API, so it depends on nothing Hardhat can change + * underneath it. + * + * The consequence is that configuration is a transaction, so every setup and + * inspection call here returns a promise and must be awaited. That is the one + * behavioural difference from smock and the reason the migration touches call + * sites at all. + * + * A transaction also mines a block, and Hardhat advances the clock a second + * per block, where smock advanced it not at all. Suites that assert on a + * boundary cannot absorb that: WalletProposalValidator stubs a 7200 second + * delay, advances time by exactly 7200 and requires + * `block.timestamp > requestedAt + minAge` to be false — any drift inverts it. + * So every write below pins the next block's timestamp to the current one, + * which needs `allowBlocksWithSameTimestamp` in hardhat.config.ts and is why + * this package is on hardhat >= 2.19. + * + * Semantics follow Foundry's `vm.mockCall` deliberately: an exact-calldata + * entry wins over a selector-wide default, and an unconfigured function + * returns the zero value of its return type, matching smock. + * + * That last part is not free. Solidity compares returndatasize against the + * size its ABI expects and reverts on a short answer, so a mock cannot answer + * an unconfigured function with empty data — it has to return a correctly + * encoded zero. Only this helper knows the mocked ABI, so it computes those + * encodings up front and installs them in the mock as a base layer that + * `reset` does not disturb. + */ + +/** + * Runs a configuration transaction without advancing the chain clock. + * + * `allowBlocksWithSameTimestamp` only *permits* a block to reuse the previous + * timestamp; it does not make it happen. Determinism comes from setting the + * next timestamp explicitly, so that is done here rather than left to how fast + * the machine happens to be. + */ +async function withoutAdvancingTime(write: () => Promise): Promise { + const { timestamp } = await ethers.provider.getBlock("latest") + await ethers.provider.send("evm_setNextBlockTimestamp", [timestamp]) + return write() +} + +/** A recorded call, decoded against the mocked interface. */ +export interface MockCall { + /** Decoded arguments, in declaration order. */ + args: unknown[] + /** `msg.value` the call carried, as smock's `getCall(n).value` did. */ + value: BigNumber +} + +/** Configuration and inspection handle for one function of a mock. */ +export interface MockedFunction { + /** + * Answers every call to this function with `value`, unless a + * `whenCalledWith` entry matches first. Call with no argument for a function + * that returns nothing. + */ + returns(value?: unknown): Promise + /** Makes every call to this function revert. */ + reverts(reason?: string): Promise + /** Narrows the next `returns`/`reverts` to one exact argument list. */ + whenCalledWith(...args: unknown[]): { + returns(value?: unknown): Promise + reverts(reason?: string): Promise + } + /** Drops every response configured for this function and every call recorded for it. */ + reset(): Promise + /** Number of calls recorded for this function. */ + callCount(): Promise + /** The i-th recorded call, decoded. */ + getCall(index: number): Promise + /** Every recorded call, decoded. */ + getCalls(): Promise +} + +export type Mock = { + [K in keyof T]: T[K] extends (...args: never[]) => unknown + ? T[K] & MockedFunction + : T[K] +} & { + address: string + /** Signer that sends from the mock's own address, as smock's `fake.wallet` did. */ + wallet: Signer + /** Underlying deployed `MockContract`, for anything this helper does not wrap. */ + mockContract: Contract + /** + * The mocked interface bound to `signer`, as smock's `FakeContract.connect` + * was. smock's fake extended `ethers.Contract` and inherited this; the proxy + * here resolves only the mocked ABI and the keys above, so without it + * `mock.connect(someone)` is `undefined`. + */ + connect(signer: Signer): Contract + /** Drops all configured responses and all recorded calls. */ + reset(): Promise + /** + * Turns call recording on or off. + * + * Recording costs real gas — smock zeroed gas for faked calls, this mock + * SSTOREs the calldata — so a test asserting on the gas of the contract + * under test should switch it off first, or it measures the mock too. + */ + setRecording(enabled: boolean): Promise +} + +/** Selectors of `MockContract`'s own administrative entry points. */ +function adminSelectors(mockInterface: Interface): Set { + return new Set( + Object.keys(mockInterface.functions) + .filter((signature) => signature.startsWith("__mock__")) + .map((signature) => mockInterface.getSighash(signature)) + ) +} + +/** + * `MockContract` answers the mocked interface through its fallback, so its own + * `__mock__*` entry points share the selector space with whatever is being + * mocked. A four-byte collision would silently shadow a real function, which + * would be a very confusing test failure. It cannot happen by accident, but it + * costs nothing to prove rather than assume. + */ +function assertNoSelectorCollision( + target: Interface, + mockInterface: Interface, + targetName: string +): void { + const reserved = adminSelectors(mockInterface) + + Object.keys(target.functions).forEach((signature) => { + const selector = target.getSighash(signature) + if (reserved.has(selector)) { + throw new Error( + `${targetName}.${signature} has selector ${selector}, which collides ` + + "with a MockContract administrative function. This mock cannot " + + "represent that interface." + ) + } + }) +} + +function fragmentsByName(target: Interface): Map { + const byName = new Map() + + Object.values(target.functions).forEach((fragment) => { + const existing = byName.get(fragment.name) + if (existing) { + existing.push(fragment) + } else { + byName.set(fragment.name, [fragment]) + } + }) + + return byName +} + +/** + * Picks the fragment to use for a name. Overloads are ambiguous by name alone; + * rather than guess, fail loudly and let the caller address the mock's + * `mockContract` directly. + */ +function resolveFragment( + fragments: FunctionFragment[], + name: string, + targetName: string +): FunctionFragment { + if (fragments.length > 1) { + throw new Error( + `${targetName}.${name} is overloaded (${fragments.length} signatures). ` + + "Address it through the mock's mockContract handle instead." + ) + } + + return fragments[0] +} + +/** + * The zero value of an ABI type, shaped the way `defaultAbiCoder` wants it. + * + * Dynamic types cannot be zero-filled word-wise, and tuples have to be built + * component by component, so this walks the type rather than assuming a flat + * layout. + */ +function zeroValueFor(type: ParamType): unknown { + if (type.baseType === "array") { + if (type.arrayLength === -1) { + return [] + } + return Array.from({ length: type.arrayLength }, () => + zeroValueFor(type.arrayChildren) + ) + } + + if (type.baseType === "tuple") { + return type.components.map((component) => zeroValueFor(component)) + } + + if (type.baseType === "address") { + return ethers.constants.AddressZero + } + + if (type.baseType === "bool") { + return false + } + + if (type.baseType === "string") { + return "" + } + + if (type.baseType === "bytes") { + return "0x" + } + + const fixedBytes = /^bytes(\d+)$/.exec(type.baseType) + if (fixedBytes) { + return `0x${"00".repeat(Number(fixedBytes[1]))}` + } + + // Everything left is uint*/int*. + return 0 +} + +/** + * Shapes a configured return value the way `defaultAbiCoder` wants it. + * + * smock accepted a named object for a multi-output function — Bridge's + * `depositParameters` returns four values and is configured as + * `{ depositDustThreshold, depositTreasuryFeeDivisor, ... }`. The coder wants + * those positionally, so they are mapped back by output name. A single-output + * function is different: an object there is a struct, and the coder handles it. + */ +function toPositional(outputs: ParamType[], value: unknown): unknown[] { + if (outputs.length === 1) { + return [value] + } + + if (Array.isArray(value)) { + return value + } + + if (value !== null && typeof value === "object") { + const named = value as Record + return outputs.map((output, index) => + output.name && output.name in named ? named[output.name] : named[index] + ) + } + + return [value] +} + +function encodeReturn(fragment: FunctionFragment, value: unknown): string { + if (fragment.outputs === null || fragment.outputs.length === 0) { + return "0x" + } + + return ethers.utils.defaultAbiCoder.encode( + fragment.outputs, + toPositional(fragment.outputs, value) + ) +} + +function encodeRevert(reason?: string): string { + if (reason === undefined) { + return "0x" + } + + return ( + ethers.utils.id("Error(string)").slice(0, 10) + + ethers.utils.defaultAbiCoder.encode(["string"], [reason]).slice(2) + ) +} + +/** + * Deploys a programmable mock answering `target`'s interface. + * + * @param target Name of the contract or interface to mock, as passed to + * `artifacts.readArtifact` — e.g. `"IBridge"`. + * @param options.address Deploy the mock at this exact address, replacing + * whatever is there. Mirrors smock's `{ address }` option. + * @returns A handle exposing each of `target`'s functions with `returns`, + * `whenCalledWith`, `reverts`, `reset`, `callCount` and `getCall`. + */ +export async function createMock( + target: string, + options: { address?: string } = {} +): Promise> { + const targetArtifact = await artifacts.readArtifact(target) + const targetInterface = new ethers.utils.Interface(targetArtifact.abi) + + const mockFactory = await ethers.getContractFactory("MockContract") + // Deploying is a transaction too, and a mock is routinely created inside a + // `before` hook after the test has already captured a baseline timestamp. + let mockContract = await withoutAdvancingTime(() => mockFactory.deploy()) + await mockContract.deployed() + + assertNoSelectorCollision(targetInterface, mockContract.interface, target) + + if (options.address !== undefined) { + // Move the deployed bytecode to the requested address, before anything is + // configured. `hardhat_setCode` copies code and not storage, and every + // configuration below — the base returns, the non-recording flags, and + // later every `returns`/`whenCalledWith` — is storage. Configuring first + // and relocating afterwards left a pinned mock with none of it. + const code = await ethers.provider.getCode(mockContract.address) + await ethers.provider.send("hardhat_setCode", [options.address, code]) + mockContract = mockContract.attach(options.address) + } + + // Install the response of last resort for every function, so an unstubbed + // one answers with a correctly sized zero instead of reverting the caller. + const baseFragments = Object.values(targetInterface.functions) + const baseSelectors = baseFragments.map((fragment) => + targetInterface.getSighash(fragment) + ) + const baseReturns = baseFragments.map((fragment) => + fragment.outputs === null || fragment.outputs.length === 0 + ? "0x" + : ethers.utils.defaultAbiCoder.encode( + fragment.outputs, + fragment.outputs.map((output) => zeroValueFor(output)) + ) + ) + await withoutAdvancingTime(() => + mockContract.__mock__setBaseReturns(baseSelectors, baseReturns) + ) + + // Flag the read-only functions. Solidity reaches them by STATICCALL, where + // the storage write recording needs is impossible, so the mock must not even + // attempt it. Doing this from the ABI rather than discovering it at runtime + // also lets `callCount`/`getCall` refuse loudly below. + const nonRecordingSelectors = baseFragments + .filter( + (fragment) => + fragment.stateMutability === "view" || + fragment.stateMutability === "pure" + ) + .map((fragment) => targetInterface.getSighash(fragment)) + if (nonRecordingSelectors.length > 0) { + await withoutAdvancingTime(() => + mockContract.__mock__setNonRecordingSelectors(nonRecordingSelectors) + ) + } + + await ethers.provider.send("hardhat_impersonateAccount", [ + mockContract.address, + ]) + await ethers.provider.send("hardhat_setBalance", [ + mockContract.address, + "0x21e19e0c9bab2400000", // 10_000 ETH, so the mock can pay for its own sends + ]) + const wallet = await ethers.getSigner(mockContract.address) + + const byName = fragmentsByName(targetInterface) + + function buildFunction(name: string): MockedFunction { + const fragment = resolveFragment(byName.get(name) ?? [], name, target) + const selector = targetInterface.getSighash(fragment) + const readOnly = + fragment.stateMutability === "view" || fragment.stateMutability === "pure" + + // Calls to a read-only function cannot be recorded, so answering "0 calls" + // would be a lie that reads as a passing assertion. Refuse instead. + const refuseIfReadOnly = () => { + if (readOnly) { + throw new Error( + `${target}.${name} is ${fragment.stateMutability}, so Solidity ` + + "reaches it by STATICCALL and the mock cannot record the call. " + + "Assert on the state-changing function that consumed the value " + + "instead." + ) + } + } + + const setForCalldata = async ( + args: unknown[], + behaviour: "return" | "revert", + payload: unknown + ): Promise => { + let callData: string + try { + callData = targetInterface.encodeFunctionData(fragment, args as never[]) + } catch (error) { + // smock stored `whenCalledWith` arguments as JavaScript values and + // compared them after decoding, so arguments that are not valid for + // the signature simply never matched and the call fell through to the + // selector-wide default. Encoding them up front turns the same + // situation into a thrown error, which would fail tests that pass + // today for reasons unrelated to this migration — a value converted to + // a Wormhole address twice, for instance, is 44 bytes where the + // signature wants 32. + // + // Keep smock's outcome — an entry that can never match — but say so, + // because it does mean the narrowing in that test is dead. + // eslint-disable-next-line no-console + console.warn( + `mock: ${target}.${name} whenCalledWith(...) arguments cannot be ` + + "encoded for this signature, so the entry can never match and " + + `is being skipped: ${(error as Error).message.split("(")[0].trim()}` + ) + return + } + + await withoutAdvancingTime(() => + behaviour === "return" + ? mockContract.__mock__setReturnForCalldata( + callData, + encodeReturn(fragment, payload) + ) + : mockContract.__mock__setRevertForCalldata( + callData, + encodeRevert(payload as string | undefined) + ) + ) + } + + const decodeCall = (callData: string, value: BigNumber): MockCall => ({ + args: Array.from( + targetInterface.decodeFunctionData(fragment, callData) + ) as unknown[], + value, + }) + + return { + async returns(value?: unknown): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setReturnForSelector( + selector, + encodeReturn(fragment, value) + ) + ) + }, + + async reverts(reason?: string): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setRevertForSelector( + selector, + encodeRevert(reason) + ) + ) + }, + + whenCalledWith(...args: unknown[]) { + return { + returns: (value?: unknown) => setForCalldata(args, "return", value), + reverts: (reason?: string) => setForCalldata(args, "revert", reason), + } + }, + + async reset(): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__resetSelector(selector) + ) + }, + + async callCount(): Promise { + refuseIfReadOnly() + + const count: BigNumberish = + await mockContract.__mock__callCountForSelector(selector) + return Number(count) + }, + + async getCall(index: number): Promise { + refuseIfReadOnly() + + const [callData, value] = await Promise.all([ + mockContract.__mock__callForSelectorAt(selector, index), + mockContract.__mock__callValueForSelectorAt(selector, index), + ]) + return decodeCall(callData as string, value as BigNumber) + }, + + async getCalls(): Promise { + refuseIfReadOnly() + + const count: BigNumberish = + await mockContract.__mock__callCountForSelector(selector) + const calls: MockCall[] = [] + + for (let i = 0; i < Number(count); i++) { + // Sequential on purpose: ordering is the point of this accessor. + // eslint-disable-next-line no-await-in-loop + const [callData, value] = await Promise.all([ + mockContract.__mock__callForSelectorAt(selector, i), + mockContract.__mock__callValueForSelectorAt(selector, i), + ]) + calls.push(decodeCall(callData as string, value as BigNumber)) + } + + return calls + }, + } + } + + const functions = new Map() + const readContract = new ethers.Contract( + mockContract.address, + targetArtifact.abi, + ethers.provider + ) + + const handle = { + address: mockContract.address, + wallet, + mockContract, + connect(signer: Signer): Contract { + return new ethers.Contract( + mockContract.address, + targetArtifact.abi, + signer + ) + }, + async reset(): Promise { + await withoutAdvancingTime(() => mockContract.__mock__reset()) + }, + async setRecording(enabled: boolean): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setRecording(enabled) + ) + }, + } + + return new Proxy(handle, { + get(base, property: string | symbol, receiver) { + if (typeof property !== "string" || property in base) { + return Reflect.get(base, property, receiver) + } + + if (!byName.has(property)) { + return Reflect.get(base, property, receiver) + } + + if (!functions.has(property)) { + // Callable like the contract itself, so a test can read through the + // mock, with the configuration surface hung off the same object — + // which is the shape smock's fakes had. + const callable = (...args: unknown[]) => readContract[property](...args) + functions.set( + property, + Object.assign(callable, buildFunction(property)) as MockedFunction + ) + } + + return functions.get(property) + }, + }) as unknown as Mock +} + +/** + * Assertion helpers replacing smock's chai matchers. + * + * smock's `expect(fake.fn).to.have.been.calledOnce` worked because the call log + * was in-process JavaScript, so a chai *property* could read it synchronously. + * Here the log is on chain, so reading it is asynchronous, and a property + * cannot be awaited — `await expect(x).to.have.been.calledOnce` would await the + * assertion object, not the count, and pass unconditionally. These are + * functions so that the `await` is real. + */ + +async function counted(fn: MockedFunction): Promise { + return fn.callCount() +} + +/** `expect(fake.fn).to.have.been.called` */ +export async function expectCalled(fn: MockedFunction): Promise { + const count = await counted(fn) + expect(count, "expected the function to have been called").to.be.greaterThan( + 0 + ) +} + +/** `expect(fake.fn).to.have.been.calledOnce` */ +export async function expectCalledOnce(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly one call").to.equal(1) +} + +/** `expect(fake.fn).to.not.have.been.called` */ +export async function expectNotCalled(fn: MockedFunction): Promise { + expect(await counted(fn), "expected no calls").to.equal(0) +} + +/** `expect(fake.fn).to.have.been.calledThrice` */ +export async function expectCalledThrice(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly three calls").to.equal(3) +} + +/** `expect(fake.fn).to.have.been.calledTwice` */ +export async function expectCalledTwice(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly two calls").to.equal(2) +} + +/** `expect(fake.fn).to.have.been.calledOnceWith(...args)` */ +/** + * Puts one recorded or expected argument into a comparable form. + * + * The two sides never arrive in the same representation. ethers decodes an ABI + * integer to a `BigNumber` above 48 bits and to a plain `number` at or below + * it, so a `uint256` argument reaches this as a `BigNumber` while the + * `uint32` getter the test compared it against yields a `number`; and a struct + * or dynamic array puts both one level down, where the previous top-level-only + * check never looked. smock compared `BigNumberish` values numerically at any + * depth, so both shapes used to pass. + * + * Numerics are wrapped rather than rendered bare, so that a genuine string + * argument of `"100"` still fails against a numeric `100`. Everything else — + * addresses, bytes, booleans — is left exactly as it came, because for those + * the two sides already agree and loosening the comparison would only hide a + * real mismatch. + */ +function normalizeForComparison(value: unknown): unknown { + if (BigNumber.isBigNumber(value)) { + return { numeric: value.toString() } + } + if (typeof value === "number" || typeof value === "bigint") { + return { numeric: value.toString() } + } + if (Array.isArray(value)) { + return value.map(normalizeForComparison) + } + return value +} + +export async function expectCalledOnceWith( + fn: MockedFunction, + args: unknown[] +): Promise { + expect(await counted(fn), "expected exactly one call").to.equal(1) + + const call = await fn.getCall(0) + + expect(call.args.length, "argument count").to.equal(args.length) + args.forEach((expected, index) => { + const actual = call.args[index] + expect(normalizeForComparison(actual), `argument ${index}`).to.deep.equal( + normalizeForComparison(expected) + ) + }) +} + +/** + * `expect(fake.fn).to.have.been.calledWith(...)` — some recorded call matched. + * + * Deliberately weaker than `expectCalledOnceWith`: smock's `calledWith` says + * nothing about how many times the function ran, so translating those sites to + * the `Once` variant would quietly add an assertion the test never made. + */ +export async function expectCalledWith( + fn: MockedFunction, + args: unknown[] +): Promise { + const calls = await fn.getCalls() + + expect(calls.length, "expected at least one call").to.be.greaterThan(0) + + const wanted = args.map(normalizeForComparison) + const seen = calls.map((call) => call.args.map(normalizeForComparison)) + + expect( + seen, + `expected a call with ${calls.length} recorded, none matching` + ).to.deep.include(wanted) +} + +export default createMock diff --git a/solidity/ecdsa/test/utils/randomBeacon.ts b/solidity/ecdsa/test/utils/randomBeacon.ts index 75bb91f592..1954b36c30 100644 --- a/solidity/ecdsa/test/utils/randomBeacon.ts +++ b/solidity/ecdsa/test/utils/randomBeacon.ts @@ -1,17 +1,15 @@ import { ethers } from "hardhat" -import { smock } from "@defi-wonderland/smock" -import chai from "chai" + +import { createMock } from "../helpers/mock" import type { BigNumber } from "ethers" import type { WalletRegistry, IRandomBeacon } from "../../typechain" -import type { FakeContract } from "@defi-wonderland/smock" - -chai.use(smock.matchers) +import type { Mock } from "../helpers/mock" export async function fakeRandomBeacon( walletRegistry: WalletRegistry -): Promise> { - const randomBeacon = await smock.fake("IRandomBeacon", { +): Promise> { + const randomBeacon = await createMock("IRandomBeacon", { address: await walletRegistry.callStatic.randomBeacon(), }) @@ -25,13 +23,9 @@ export async function fakeRandomBeacon( return randomBeacon } -export function resetMock(randomBeacon: FakeContract): void { - randomBeacon.requestRelayEntry.reset() -} - export async function submitRelayEntry( walletRegistry: WalletRegistry, - randomBeacon?: FakeContract + randomBeacon?: Mock ): Promise<{ startBlock: number dkgSeed: BigNumber diff --git a/solidity/ecdsa/test/utils/wallets.ts b/solidity/ecdsa/test/utils/wallets.ts index f6f71b0ea2..f7d4558136 100644 --- a/solidity/ecdsa/test/utils/wallets.ts +++ b/solidity/ecdsa/test/utils/wallets.ts @@ -4,9 +4,8 @@ import { params } from "../fixtures" import ecdsaData from "../data/ecdsa" import { noMisbehaved, signAndSubmitCorrectDkgResult } from "./dkg" -import { resetMock } from "./randomBeacon" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "../helpers/mock" import type { DkgResult } from "./dkg" import type { IRandomBeacon, WalletRegistry } from "../../typechain" import type { Operator } from "./operators" @@ -19,7 +18,7 @@ const { keccak256 } = ethers.utils export async function createNewWallet( walletRegistry: WalletRegistry, walletOwner: Signer, - randomBeacon: FakeContract, + randomBeacon: Mock, publicKey: BytesLike = ecdsaData.group1.publicKey ): Promise<{ members: Operator[] @@ -58,8 +57,6 @@ export async function createNewWallet( .connect(submitter) .approveDkgResult(dkgResult) - resetMock(randomBeacon) - return { members, dkgResult, diff --git a/solidity/ecdsa/yarn.lock b/solidity/ecdsa/yarn.lock index 65b7e31354..c8ffd46962 100644 --- a/solidity/ecdsa/yarn.lock +++ b/solidity/ecdsa/yarn.lock @@ -165,29 +165,6 @@ __metadata: languageName: node linkType: hard -"@defi-wonderland/smock@npm:2.3.4": - version: 2.3.4 - resolution: "@defi-wonderland/smock@npm:2.3.4" - dependencies: - "@nomicfoundation/ethereumjs-evm": "npm:^1.0.0-rc.3" - "@nomicfoundation/ethereumjs-util": "npm:^8.0.0-rc.3" - "@nomicfoundation/ethereumjs-vm": "npm:^6.0.0-rc.3" - diff: "npm:^5.0.0" - lodash.isequal: "npm:^4.5.0" - lodash.isequalwith: "npm:^4.4.0" - rxjs: "npm:^7.2.0" - semver: "npm:^7.3.5" - peerDependencies: - "@ethersproject/abi": ^5 - "@ethersproject/abstract-provider": ^5 - "@ethersproject/abstract-signer": ^5 - "@nomiclabs/hardhat-ethers": ^2 - ethers: ^5 - hardhat: ^2 - checksum: 10c0/3181d6881d9447fed6afcd7e69307e2d314a3e3b35384ccfe31b0979c53be25864d74644daa16378d2273efde3b50917b5f81149ea9fd828b5da3cd022b85c42 - languageName: node - linkType: hard - "@eslint/eslintrc@npm:^0.4.3": version: 0.4.3 resolution: "@eslint/eslintrc@npm:0.4.3" @@ -1488,7 +1465,6 @@ __metadata: version: 0.0.0-use.local resolution: "@keep-network/ecdsa@workspace:." dependencies: - "@defi-wonderland/smock": "npm:2.3.4" "@keep-network/hardhat-helpers": "npm:^0.6.0-pre.15" "@keep-network/hardhat-local-networks-config": "npm:^0.1.0-pre.4" "@keep-network/random-beacon": "npm:development" @@ -1697,20 +1673,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-block@npm:4.2.2": - version: 4.2.2 - resolution: "@nomicfoundation/ethereumjs-block@npm:4.2.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-tx": "npm:4.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/1c211294b3064d2bbfcf33b460438f01fb9cd77429314a90a5e2ffce5162019a384f4ae7d3825cfd386a140db191b251b475427562c53f85beffc786156f817e - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-block@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-block@npm:5.0.2" @@ -1726,26 +1688,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-blockchain@npm:6.2.2": - version: 6.2.2 - resolution: "@nomicfoundation/ethereumjs-blockchain@npm:6.2.2" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-ethash": "npm:2.0.5" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - abstract-level: "npm:^1.0.3" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - level: "npm:^8.0.0" - lru-cache: "npm:^5.1.1" - memory-level: "npm:^1.0.0" - checksum: 10c0/6fe6e315900e1d6a29d59be41f566bdfd5ffdf82ab0fe081b1999dcc4eec3a248ab080d359a56e8cde4e473ca90349b0c50fa1ab707aa3e275fb1c478237e5e2 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-blockchain@npm:7.0.2": version: 7.0.2 resolution: "@nomicfoundation/ethereumjs-blockchain@npm:7.0.2" @@ -1767,16 +1709,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-common@npm:3.1.2": - version: 3.1.2 - resolution: "@nomicfoundation/ethereumjs-common@npm:3.1.2" - dependencies: - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - crc-32: "npm:^1.2.0" - checksum: 10c0/90910630025b5bb503f36125c45395cc9f875ffdd8137a83e9c1d566678edcc8db40f8ce1dff9da1ef2c91c7d6b6d1fa75c41a9579a5d3a8f0ae669fcea244b1 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-common@npm:4.0.2": version: 4.0.2 resolution: "@nomicfoundation/ethereumjs-common@npm:4.0.2" @@ -1787,20 +1719,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-ethash@npm:2.0.5": - version: 2.0.5 - resolution: "@nomicfoundation/ethereumjs-ethash@npm:2.0.5" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - abstract-level: "npm:^1.0.3" - bigint-crypto-utils: "npm:^3.0.23" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/7a90ef53ae4c1ac5a314c3447966fdbefcc96481ae3a05d59e881053350c55be7c841708c61c79a2af40bbb0181d6e0db42601592f17a4db1611b199d49e8544 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-ethash@npm:3.0.2": version: 3.0.2 resolution: "@nomicfoundation/ethereumjs-ethash@npm:3.0.2" @@ -1815,22 +1733,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-evm@npm:1.3.2, @nomicfoundation/ethereumjs-evm@npm:^1.0.0-rc.3": - version: 1.3.2 - resolution: "@nomicfoundation/ethereumjs-evm@npm:1.3.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - "@types/async-eventemitter": "npm:^0.2.1" - async-eventemitter: "npm:^0.2.4" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - mcl-wasm: "npm:^0.7.1" - rustbn.js: "npm:~0.2.0" - checksum: 10c0/4aa14d7dce597a91c25bec5975022348741cebf6ed20cda028ddcbebe739ba2e6f4c879fa1ebe849bd5c78d3fd2443ebbb7d57e1fca5a98fbe88fc9ce15d9fd6 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-evm@npm:2.0.2": version: 2.0.2 resolution: "@nomicfoundation/ethereumjs-evm@npm:2.0.2" @@ -1847,15 +1749,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-rlp@npm:4.0.3": - version: 4.0.3 - resolution: "@nomicfoundation/ethereumjs-rlp@npm:4.0.3" - bin: - rlp: bin/rlp - checksum: 10c0/3e3c07abf53ff5832afbbdf3f3687e11e2e829699348eea1ae465084c72e024559d97e351e8f0fb27f32c7896633c7dd50b19d8de486e89cde777fd5447381cd - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-rlp@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-rlp@npm:5.0.2" @@ -1865,21 +1758,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-statemanager@npm:1.0.5": - version: 1.0.5 - resolution: "@nomicfoundation/ethereumjs-statemanager@npm:1.0.5" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - functional-red-black-tree: "npm:^1.0.1" - checksum: 10c0/4a05b7a86a1bbc8fd409416edf437d99d9d4498c438e086c30250cb3dc92ff00086f9ff959f469c72d46178e831104dc15f10465e27fbbf0ca97da27d6889a0c - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-statemanager@npm:2.0.2": version: 2.0.2 resolution: "@nomicfoundation/ethereumjs-statemanager@npm:2.0.2" @@ -1894,18 +1772,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-trie@npm:5.0.5": - version: 5.0.5 - resolution: "@nomicfoundation/ethereumjs-trie@npm:5.0.5" - dependencies: - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - readable-stream: "npm:^3.6.0" - checksum: 10c0/cab544fef4bcdc3acef1bfb4ee9f2fde44b66a22b2329bfd67515facdf115a318961f8bc0e38befded838e8fc513974f90f340b53a98b8469e54960b15cd857a - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-trie@npm:6.0.2": version: 6.0.2 resolution: "@nomicfoundation/ethereumjs-trie@npm:6.0.2" @@ -1919,18 +1785,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-tx@npm:4.1.2": - version: 4.1.2 - resolution: "@nomicfoundation/ethereumjs-tx@npm:4.1.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/cb569c882d3ce922acff1a4238864f11109ac5a30dfa481b1ed9c7043c2b773f3a5fc88a3f4fefb62b11c448305296533f555f93d1d969a5abd3c2a13c80ed74 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-tx@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-tx@npm:5.0.2" @@ -1945,16 +1799,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-util@npm:8.0.6, @nomicfoundation/ethereumjs-util@npm:^8.0.0-rc.3": - version: 8.0.6 - resolution: "@nomicfoundation/ethereumjs-util@npm:8.0.6" - dependencies: - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/647006f4dfa962f61cec54c34ff9939468042cf762ff3b2cf80c8362558f21750348a3cda63dc9890b1cb2ba664f97dc4a892afca5f5d6f95b3ba4d56be5a33b - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-util@npm:9.0.2": version: 9.0.2 resolution: "@nomicfoundation/ethereumjs-util@npm:9.0.2" @@ -1987,30 +1831,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-vm@npm:^6.0.0-rc.3": - version: 6.4.2 - resolution: "@nomicfoundation/ethereumjs-vm@npm:6.4.2" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-blockchain": "npm:6.2.2" - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-evm": "npm:1.3.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-statemanager": "npm:1.0.5" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-tx": "npm:4.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - "@types/async-eventemitter": "npm:^0.2.1" - async-eventemitter: "npm:^0.2.4" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - functional-red-black-tree: "npm:^1.0.1" - mcl-wasm: "npm:^0.7.1" - rustbn.js: "npm:~0.2.0" - checksum: 10c0/78e4b0ba20e8fa4ef112bae88f432746647ed48b41918b34855fe08269be3aaff84f95c08b6c61475fb70f24a28ba73612bd2bcd19b3c007c8bf9e11a43fa8e0 - languageName: node - linkType: hard - "@nomicfoundation/hardhat-chai-matchers@npm:^1.0.6": version: 1.0.6 resolution: "@nomicfoundation/hardhat-chai-matchers@npm:1.0.6" @@ -2716,15 +2536,6 @@ __metadata: languageName: node linkType: hard -"@types/async-eventemitter@npm:^0.2.1": - version: 0.2.4 - resolution: "@types/async-eventemitter@npm:0.2.4" - dependencies: - "@types/events": "npm:*" - checksum: 10c0/2ae267eb3e959fe5aaf6d850ab06ac2e5b44f1a7e3e421250f3ebaa8a108f641e9050d042980bc35aab98d6fa5f1a62a43cfb7f377011ce9013ed62229327111 - languageName: node - linkType: hard - "@types/bn.js@npm:^4.11.3, @types/bn.js@npm:^4.11.4": version: 4.11.6 resolution: "@types/bn.js@npm:4.11.6" @@ -2768,13 +2579,6 @@ __metadata: languageName: node linkType: hard -"@types/events@npm:*": - version: 3.0.3 - resolution: "@types/events@npm:3.0.3" - checksum: 10c0/3a56f8c51eb4ebc0d05dcadca0c6636c816acc10216ce36c976fad11e54a01f4bb979a07211355686015884753b37f17d74bfdc7aaf4ebb027c1e8a501c7b21d - languageName: node - linkType: hard - "@types/json-schema@npm:^7.0.7": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -3517,15 +3321,6 @@ __metadata: languageName: node linkType: hard -"async-eventemitter@npm:^0.2.4": - version: 0.2.4 - resolution: "async-eventemitter@npm:0.2.4" - dependencies: - async: "npm:^2.4.0" - checksum: 10c0/ce761d1837d454efb456bd2bd5b0db0e100f600d66d9a07a9f7772e0cfd5ad3029bb07385310bd1c7d65603735b755ba457a2f8ed47fb1314a6fe275dd69a322 - languageName: node - linkType: hard - "async-function@npm:^1.0.0": version: 1.0.0 resolution: "async-function@npm:1.0.0" @@ -3549,15 +3344,6 @@ __metadata: languageName: node linkType: hard -"async@npm:^2.4.0": - version: 2.6.3 - resolution: "async@npm:2.6.3" - dependencies: - lodash: "npm:^4.17.14" - checksum: 10c0/06c917c74a55f9036ff79dedfc51dfc9c52c2dee2f80866b600495d2fd3037251dbcfde6592f23fc47398c44d844174004e0ee532f94c32a888bb89fd1cf0f25 - languageName: node - linkType: hard - "asynckit@npm:^0.4.0": version: 0.4.0 resolution: "asynckit@npm:0.4.0" @@ -5104,7 +4890,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:5.0.0, diff@npm:^5.0.0": +"diff@npm:5.0.0": version: 5.0.0 resolution: "diff@npm:5.0.0" checksum: 10c0/08c5904779bbababcd31f1707657b1ad57f8a9b65e6f88d3fb501d09a965d5f8d73066898a7d3f35981f9e4101892c61d99175d421f3b759533213c253d91134 @@ -8550,20 +8336,6 @@ __metadata: languageName: node linkType: hard -"lodash.isequal@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f - languageName: node - linkType: hard - -"lodash.isequalwith@npm:^4.4.0": - version: 4.4.0 - resolution: "lodash.isequalwith@npm:4.4.0" - checksum: 10c0/edb7f01c6d949fad36c756e7b1af6ee1df8b9663cee62880186a3b241e133a981bc7eed42cf14715a58f939d6d779185c3ead0c3f0d617d1ad59f50b423eb5d5 - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -10582,15 +10354,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.2.0": - version: 7.5.3 - resolution: "rxjs@npm:7.5.3" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/fa06ec3e95de4d3c6cb10879e5d560af4685e5b52ffff6008bf0e15321a45d978069fc4fd1913fdd877556b0985a647673cbaa4cd5a5ed5cd96567af857bfbcd - languageName: node - linkType: hard - "safe-array-concat@npm:^1.1.3": version: 1.1.3 resolution: "safe-array-concat@npm:1.1.3" @@ -11773,13 +11536,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0": - version: 2.3.1 - resolution: "tslib@npm:2.3.1" - checksum: 10c0/4efd888895bdb3b987086b2b7793ad1013566f882b0eb7a328384e5ecc0d71cafb16bbeab3196200cbf7f01a73ccc25acc2f131d4ea6ee959be7436a8a306482 - languageName: node - linkType: hard - "tslib@npm:^2.3.1, tslib@npm:^2.6.2": version: 2.8.1 resolution: "tslib@npm:2.8.1" diff --git a/solidity/random-beacon/contracts/test/IMockTarget.sol b/solidity/random-beacon/contracts/test/IMockTarget.sol new file mode 100644 index 0000000000..204ed4c3e6 --- /dev/null +++ b/solidity/random-beacon/contracts/test/IMockTarget.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +/// @notice Test-only interface exercising the shapes `MockContract` has to +/// answer: a view function reached by STATICCALL, a state-changing +/// function whose calls are asserted on, a function returning nothing, +/// a struct return and a multi-value return. +interface IMockTarget { + struct Info { + address owner; + uint64 createdAt; + bool active; + } + + function doThing(address who, uint256 amount) external returns (bool); + + function noReturn(uint256 value) external; + + function readValue(uint256 key) external view returns (uint256); + + function readInfo(uint256 key) external view returns (Info memory); + + function readPair(uint256 key) external view returns (uint32, uint32); +} diff --git a/solidity/random-beacon/contracts/test/MockContract.sol b/solidity/random-beacon/contracts/test/MockContract.sol new file mode 100644 index 0000000000..97bf06c1f9 --- /dev/null +++ b/solidity/random-beacon/contracts/test/MockContract.sol @@ -0,0 +1,518 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +/// @notice Test-only programmable mock. Answers any call according to a table +/// set up from the test, and records the calls it receives. +/// +/// This is the contract half of the replacement for the archived +/// defi-wonderland smock package. smock worked by reaching into +/// Hardhat's provider internals, which is why it broke on Hardhat +/// >= 2.20 and why it cannot be carried forward. Everything here is +/// ordinary EVM state, so it survives any Hardhat, ethers, viem or +/// Foundry change. +/// +/// The model is deliberately Foundry's `vm.mockCall`: a +/// calldata-to-returndata table plus call recording. If the suite ever +/// moves to Solidity tests, the semantics carry over unchanged. +/// +/// Lookup order for an incoming call, first match wins: +/// 1. exact full-calldata match — smock's `whenCalledWith` +/// 2. selector match — smock's bare `returns` +/// 3. the response of last resort installed by the helper when the +/// mock was created: the zero value of the function's return +/// type, which is how smock answered an unstubbed function +/// +/// Reverts are configured the same way and take priority over returns +/// at the same specificity. +/// +/// @dev Administrative entry points are prefixed `__mock__` so they are +/// addressable alongside whatever interface is being mocked. A four-byte +/// collision between one of them and a real function of the mocked +/// interface would silently shadow that function, so the TypeScript helper +/// asserts no collision exists at construction time rather than leaving it +/// to chance. +contract MockContract { + // The `__mock__` prefix namespaces this contract's own entry points away + // from whatever interface it is answering, which is the point; mixedCase + // would defeat it. + // solhint-disable func-name-mixedcase + + enum Behaviour { + Unset, + Return, + Revert + } + + struct Response { + Behaviour behaviour; + bytes data; + } + + /// @dev All mock state lives behind one hashed base slot. + /// + /// Mocks are routinely installed over an address that already holds a + /// deployed contract — `test/fixtures/bridge.ts` pins them at the + /// Bridge's real `ecdsaWalletRegistry` and `relay` addresses, and 19 + /// call sites pass an explicit address. `hardhat_setCode` replaces the + /// code but leaves that contract's storage behind, so state at slots + /// 0, 1, 2... would be read back as configuration. An ERC-7201-style + /// base slot puts this contract's state where nothing else has been. + struct State { + /// @dev keccak256(full calldata) => response. + mapping(bytes32 => Response) responseByCalldata; + /// @dev selector => response. + mapping(bytes4 => Response) responseBySelector; + /// @dev selector => response of last resort, installed once when the + /// mock is created and never cleared by `reset`. + /// + /// Solidity checks returndatasize against the size its ABI expects + /// and reverts on a short answer, so an unconfigured function + /// cannot simply return nothing: it has to return a correctly + /// encoded zero. Only the helper knows the mocked ABI, so it + /// computes those encodings and installs them here. That + /// reproduces smock, where an unstubbed function yields the zero + /// value of its return type. + mapping(bytes4 => Response) baseResponseBySelector; + /// @dev Selectors that must never be recorded, because the mocked ABI + /// declares them `view` or `pure` and so they arrive by + /// STATICCALL. See `__mock__record`. + mapping(bytes4 => bool) nonRecording; + /// @dev Keys of every exact-calldata entry configured so far, with the + /// selector each belongs to. Exact-calldata entries are keyed by + /// hash and so cannot be enumerated from the mapping; `reset` + /// needs to clear them, and inferring them from recorded calls + /// would be wrong because view calls are never recorded. + bytes32[] configuredCalldataKeys; + mapping(bytes32 => bytes4) selectorOfCalldataKey; + mapping(bytes32 => bool) calldataKeyKnown; + /// @dev Selectors given a default response, for the same reason. + bytes4[] configuredSelectors; + mapping(bytes4 => bool) selectorKnown; + /// @dev Every call recorded, in order, as raw calldata. Kept whole + /// rather than decoded so the helper can decode against whichever + /// ABI the test declared. + bytes[] receivedCalls; + /// @dev msg.value of each recorded call, parallel to `receivedCalls`. + /// smock exposed this as `getCall(n).value`, and payable mocks + /// are asserted on it -- `transferTokensWithPayload` carries the + /// Wormhole message fee. + uint256[] receivedValues; + /// @dev Set to disable recording entirely. + /// + /// Recording costs real gas -- smock zeroed gas for faked calls, + /// this contract SSTOREs the calldata -- so a test measuring the + /// gas of the contract under test would otherwise be measuring + /// the mock's bookkeeping too. + bool recordingDisabled; + } + + /// @dev keccak256("tbtc.test.MockContract.state.v1") - 1, masked, per the + /// ERC-7201 convention. + /// @dev Gas handed to the recording self-call. + /// + /// An explicit cap matters more than the value. try/catch does not + /// recover from out-of-gas: an uncapped sub-call takes 63/64 of what + /// is left, so if recording runs out, the caller is left with too + /// little to continue and the whole transaction reverts. Capping it + /// means a failed recording costs this much and nothing more, which + /// keeps recording genuinely best-effort. Generous enough for any + /// realistic calldata. + uint256 private constant RECORD_GAS_STIPEND = 1_000_000; + + bytes32 private constant STATE_SLOT = + 0xdd9627cd601a6555f69239ac336fba8db95c419564b84ebb3ee2c21fed88ae00; + + function _state() private pure returns (State storage state) { + bytes32 slot = STATE_SLOT; + // solhint-disable-next-line no-inline-assembly + assembly { + state.slot := slot + } + } + + /// @notice Configures the response for one exact calldata payload. + /// @param callData Full ABI-encoded calldata, selector included. + /// @param returnData ABI-encoded return value. Empty for void functions. + function __mock__setReturnForCalldata( + bytes calldata callData, + bytes calldata returnData + ) external { + __mock__rememberCalldataKey(callData); + _state().responseByCalldata[keccak256(callData)] = Response( + Behaviour.Return, + returnData + ); + } + + /// @notice Configures the default response for a selector, used when no + /// exact-calldata entry matches. + function __mock__setReturnForSelector( + bytes4 selector, + bytes calldata returnData + ) external { + __mock__rememberSelector(selector); + _state().responseBySelector[selector] = Response( + Behaviour.Return, + returnData + ); + } + + /// @notice Makes one exact calldata payload revert. + /// @param revertData Raw revert payload. Empty reverts with no data. + function __mock__setRevertForCalldata( + bytes calldata callData, + bytes calldata revertData + ) external { + __mock__rememberCalldataKey(callData); + _state().responseByCalldata[keccak256(callData)] = Response( + Behaviour.Revert, + revertData + ); + } + + /// @notice Makes every call to a selector revert, unless an exact-calldata + /// entry matches first. + function __mock__setRevertForSelector( + bytes4 selector, + bytes calldata revertData + ) external { + __mock__rememberSelector(selector); + _state().responseBySelector[selector] = Response( + Behaviour.Revert, + revertData + ); + } + + /// @notice Clears every response configured for one selector and forgets + /// the calls recorded for it. This is smock's `fn.reset()`. + function __mock__resetSelector(bytes4 selector) external { + delete _state().responseBySelector[selector]; + + uint256 keptKeys = 0; + uint256 totalKeys = _state().configuredCalldataKeys.length; + for (uint256 i = 0; i < totalKeys; i++) { + bytes32 key = _state().configuredCalldataKeys[i]; + + if (_state().selectorOfCalldataKey[key] == selector) { + delete _state().responseByCalldata[key]; + delete _state().selectorOfCalldataKey[key]; + delete _state().calldataKeyKnown[key]; + } else { + _state().configuredCalldataKeys[keptKeys] = key; + keptKeys++; + } + } + while (_state().configuredCalldataKeys.length > keptKeys) { + _state().configuredCalldataKeys.pop(); + } + + uint256 keptCalls = 0; + uint256 totalCalls = _state().receivedCalls.length; + for (uint256 i = 0; i < totalCalls; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) != selector) { + _state().receivedCalls[keptCalls] = _state().receivedCalls[i]; + _state().receivedValues[keptCalls] = _state().receivedValues[i]; + keptCalls++; + } + } + while (_state().receivedCalls.length > keptCalls) { + _state().receivedCalls.pop(); + _state().receivedValues.pop(); + } + } + + /// @notice Clears every configured response and every recorded call. + function __mock__reset() external { + uint256 totalKeys = _state().configuredCalldataKeys.length; + for (uint256 i = 0; i < totalKeys; i++) { + bytes32 key = _state().configuredCalldataKeys[i]; + delete _state().responseByCalldata[key]; + delete _state().selectorOfCalldataKey[key]; + delete _state().calldataKeyKnown[key]; + } + delete _state().configuredCalldataKeys; + + uint256 totalSelectors = _state().configuredSelectors.length; + for (uint256 i = 0; i < totalSelectors; i++) { + delete _state().responseBySelector[_state().configuredSelectors[i]]; + delete _state().selectorKnown[_state().configuredSelectors[i]]; + } + delete _state().configuredSelectors; + + delete _state().receivedCalls; + delete _state().receivedValues; + } + + /// @notice Installs the responses of last resort. Called once by the + /// helper at construction with the zero value of every function's + /// return type. + function __mock__setBaseReturns( + bytes4[] calldata selectors, + bytes[] calldata returnData + ) external { + require( + selectors.length == returnData.length, + "MockContract: length mismatch" + ); + + for (uint256 i = 0; i < selectors.length; i++) { + _state().baseResponseBySelector[selectors[i]] = Response( + Behaviour.Return, + returnData[i] + ); + } + } + + /// @notice Turns call recording on or off for this mock. + function __mock__setRecording(bool enabled) external { + _state().recordingDisabled = !enabled; + } + + /// @notice Marks selectors that must never be recorded. The helper calls + /// this once with every `view` and `pure` function of the mocked + /// ABI. + function __mock__setNonRecordingSelectors(bytes4[] calldata selectors) + external + { + for (uint256 i = 0; i < selectors.length; i++) { + _state().nonRecording[selectors[i]] = true; + } + } + + /// @notice Whether a selector is excluded from recording. + function __mock__isNonRecording(bytes4 selector) + external + view + returns (bool) + { + return _state().nonRecording[selector]; + } + + /// @notice Clears the default response for one selector without touching + /// its exact-calldata entries or recorded calls. + function __mock__clearSelector(bytes4 selector) external { + delete _state().responseBySelector[selector]; + } + + /// @notice Appends a recorded call. Only ever invoked by this contract, on + /// itself, from `fallback`. + /// @dev Recording is a storage write, so it is impossible when the mocked + /// function was reached by STATICCALL — which is what Solidity emits + /// for a `view` or `pure` function on the mocked interface. Routing + /// the write through an external self-call lets `fallback` attempt it + /// and carry on when it fails, so a stubbed view function still + /// answers instead of reverting. + /// + /// The cost is that calls to view functions are not recorded, and so + /// cannot be asserted on. That is sound for this suite: every call + /// assertion in it targets a state-changing function, and a `view` + /// that a test wanted to count could not have been reached by + /// STATICCALL in the first place. + function __mock__record(bytes calldata callData, uint256 value) external { + require( + msg.sender == address(this), + "MockContract: recording is internal" + ); + _state().receivedCalls.push(callData); + _state().receivedValues.push(value); + } + + /// @notice Number of calls recorded, across all selectors. + function __mock__callCount() external view returns (uint256) { + return _state().receivedCalls.length; + } + + /// @notice Raw calldata of the i-th call recorded, across all selectors. + function __mock__callAt(uint256 index) + external + view + returns (bytes memory) + { + return _state().receivedCalls[index]; + } + + /// @notice Number of calls recorded for one selector. + function __mock__callCountForSelector(bytes4 selector) + external + view + returns (uint256 count) + { + uint256 total = _state().receivedCalls.length; + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + count++; + } + } + } + + /// @notice Raw calldata of the i-th call recorded for one selector. + /// @notice msg.value of the i-th call recorded for one selector. + function __mock__callValueForSelectorAt(bytes4 selector, uint256 index) + external + view + returns (uint256) + { + uint256 seen = 0; + uint256 total = _state().receivedCalls.length; + + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + if (seen == index) { + return _state().receivedValues[i]; + } + seen++; + } + } + + revert("MockContract: call index out of range"); + } + + function __mock__callForSelectorAt(bytes4 selector, uint256 index) + external + view + returns (bytes memory) + { + uint256 seen = 0; + uint256 total = _state().receivedCalls.length; + + for (uint256 i = 0; i < total; i++) { + if (__mock__selectorOf(_state().receivedCalls[i]) == selector) { + if (seen == index) { + return _state().receivedCalls[i]; + } + seen++; + } + } + + revert("MockContract: call index out of range"); + } + + /// @dev Leading four bytes of `callData`, or zero if it is shorter. + function __mock__selectorOf(bytes memory callData) + public + pure + returns (bytes4 selector) + { + if (callData.length < 4) { + return bytes4(0); + } + + // solhint-disable-next-line no-inline-assembly + assembly { + selector := mload(add(callData, 32)) + } + } + + // A typed `fallback(bytes calldata) returns (bytes memory)` would read + // better, but prettier-plugin-solidity silently rewrites it to + // `fallback() external payable`, dropping the parameter and the return + // type — a formatter quietly changing semantics. Reading msg.data and + // returning through assembly is immune to that. + // solhint-disable-next-line no-complex-fallback + fallback() external payable { + // A `view` or `pure` function on the mocked ABI arrives by STATICCALL, + // where the storage write recording needs is impossible. Those + // selectors are flagged up front so the attempt is skipped outright + // rather than made and swallowed — which keeps them off the gas budget + // of the hot path, SPV proofs being the case that matters. + // + // The try/catch still guards the rest: a state-changing function is + // also reached statically under `eth_call`/`callStatic`. + if ( + !_state().recordingDisabled && + !_state().nonRecording[__mock__selectorOf(msg.data)] + ) { + // solhint-disable-next-line no-empty-blocks + try + this.__mock__record{gas: RECORD_GAS_STIPEND}( + msg.data, + msg.value + ) + {} catch {} + } + + bytes memory result = __mock__responseFor(msg.data); + + // solhint-disable-next-line no-inline-assembly + assembly { + return(add(result, 32), mload(result)) + } + } + + // solhint-disable-next-line no-empty-blocks + receive() external payable {} + + /// @dev Resolves an incoming call against the three layers, most specific + /// first. Reverts here if the matched response is a configured revert. + function __mock__responseFor(bytes memory callData) + private + view + returns (bytes memory) + { + Response storage exact = _state().responseByCalldata[ + keccak256(callData) + ]; + if (exact.behaviour != Behaviour.Unset) { + return __mock__respond(exact); + } + + bytes4 selector = __mock__selectorOf(callData); + + Response storage bySelector = _state().responseBySelector[selector]; + if (bySelector.behaviour != Behaviour.Unset) { + return __mock__respond(bySelector); + } + + Response storage base = _state().baseResponseBySelector[selector]; + if (base.behaviour != Behaviour.Unset) { + return __mock__respond(base); + } + + // Last resort: a block of zeroes long enough to decode as the zero + // value of essentially any return shape. + // + // Empty returndata will not do. Solidity compares returndatasize + // against the size its ABI expects and reverts the caller on a short + // answer, and the helper's per-function zeroes only cover the ABI it + // was given. Production code reaches functions outside that ABI -- + // `AbstractBTCDepositor` calls `bridge.depositParameters()` through its + // own view of the Bridge -- and smock answered those too, with exactly + // this: a fixed run of zero bytes. + return new bytes(2048); + } + + function __mock__rememberSelector(bytes4 selector) private { + if (!_state().selectorKnown[selector]) { + _state().selectorKnown[selector] = true; + _state().configuredSelectors.push(selector); + } + } + + function __mock__rememberCalldataKey(bytes calldata callData) private { + bytes32 key = keccak256(callData); + + if (!_state().calldataKeyKnown[key]) { + _state().calldataKeyKnown[key] = true; + _state().selectorOfCalldataKey[key] = __mock__selectorOf(callData); + _state().configuredCalldataKeys.push(key); + } + } + + function __mock__respond(Response storage response) + private + view + returns (bytes memory) + { + if (response.behaviour == Behaviour.Revert) { + bytes memory revertData = response.data; + + // solhint-disable-next-line no-inline-assembly + assembly { + revert(add(revertData, 32), mload(revertData)) + } + } + + return response.data; + } +} diff --git a/solidity/random-beacon/contracts/test/MockTargetConsumer.sol b/solidity/random-beacon/contracts/test/MockTargetConsumer.sol new file mode 100644 index 0000000000..45c30efae4 --- /dev/null +++ b/solidity/random-beacon/contracts/test/MockTargetConsumer.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-only + +pragma solidity ^0.8.0; + +import "./IMockTarget.sol"; + +/// @notice Test-only contract under test. It reaches the mocked interface the +/// way production code does — in particular, `view` functions are +/// reached by STATICCALL, which is the case a recording mock has to +/// survive without reverting. +contract MockTargetConsumer { + IMockTarget public immutable target; + + uint256 public lastValue; + bool public lastResult; + + constructor(IMockTarget _target) { + target = _target; + } + + /// @dev STATICCALL inside a state-changing function. + function cacheValue(uint256 key) external { + lastValue = target.readValue(key); + } + + /// @dev CALL: recorded by the mock and asserted on by the test. + function doThing(address who, uint256 amount) external { + lastResult = target.doThing(who, amount); + } + + function noReturn(uint256 value) external { + target.noReturn(value); + } + + /// @dev STATICCALL: `readValue` is `view` on the interface. + function readValueThroughStaticCall(uint256 key) + external + view + returns (uint256) + { + return target.readValue(key); + } + + function readInfoThroughStaticCall(uint256 key) + external + view + returns (IMockTarget.Info memory) + { + return target.readInfo(key); + } + + function readPairThroughStaticCall(uint256 key) + external + view + returns (uint32, uint32) + { + return target.readPair(key); + } +} diff --git a/solidity/random-beacon/hardhat.config.ts b/solidity/random-beacon/hardhat.config.ts index 143d827a11..74c685e616 100644 --- a/solidity/random-beacon/hardhat.config.ts +++ b/solidity/random-beacon/hardhat.config.ts @@ -86,6 +86,13 @@ const config: HardhatUserConfig = { }, networks: { hardhat: { + // Configuring a `MockContract` is a transaction, and a transaction mines + // a block. Without this, Hardhat forces every block to be at least one + // second after its parent, so setting up a mock silently advances the + // chain clock and any test asserting on a deadline boundary inverts. + // `test/helpers/mock.ts` pins the next block's timestamp before each + // write; this option is what lets it reuse the current one. + allowBlocksWithSameTimestamp: true, forking: { // forking is enabled only if FORKING_URL env is provided enabled: !!process.env.FORKING_URL, diff --git a/solidity/random-beacon/package.json b/solidity/random-beacon/package.json index 8d45dd5429..8b80c2cdcd 100644 --- a/solidity/random-beacon/package.json +++ b/solidity/random-beacon/package.json @@ -40,7 +40,6 @@ "@threshold-network/solidity-contracts": "1.3.0-dev.14" }, "devDependencies": { - "@defi-wonderland/smock": "2.3.4", "@keep-network/hardhat-helpers": "^0.6.0-pre.15", "@keep-network/hardhat-local-networks-config": "^0.1.0-pre.0", "@nomiclabs/hardhat-ethers": "^2.0.6", diff --git a/solidity/random-beacon/test/RandomBeacon.Authorization.test.ts b/solidity/random-beacon/test/RandomBeacon.Authorization.test.ts index 8c3307ff03..6520f5bb01 100644 --- a/solidity/random-beacon/test/RandomBeacon.Authorization.test.ts +++ b/solidity/random-beacon/test/RandomBeacon.Authorization.test.ts @@ -1,12 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ import { ethers, helpers } from "hardhat" -import { smock } from "@defi-wonderland/smock" +import { createMock } from "./helpers/mock" import { expect } from "chai" import { constants, params, randomBeaconDeployment } from "./fixtures" import { legacyTokenStakingAt } from "./utils/operators" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { BigNumber, BigNumberish, ContractTransaction } from "ethers" import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { @@ -42,7 +42,7 @@ describe("RandomBeacon - Authorization", () => { let authorizer: SignerWithAddress let beneficiary: SignerWithAddress let thirdParty: SignerWithAddress - let slasher: FakeContract + let slasher: Mock const stakedAmount = to1e18(1_000_000) // 1MM T let minimumAuthorization: BigNumber @@ -73,7 +73,7 @@ describe("RandomBeacon - Authorization", () => { // Initialize slasher - fake application capable of slashing the // staking provider. - slasher = await smock.fake("IApplication") + slasher = await createMock("IApplication") await legacyTokenStakingAt(staking, deployer).approveApplication( slasher.address ) diff --git a/solidity/random-beacon/test/RandomBeacon.GroupCreation.test.ts b/solidity/random-beacon/test/RandomBeacon.GroupCreation.test.ts index aed71bf16c..839f720b95 100644 --- a/solidity/random-beacon/test/RandomBeacon.GroupCreation.test.ts +++ b/solidity/random-beacon/test/RandomBeacon.GroupCreation.test.ts @@ -23,7 +23,7 @@ import type { SignerWithAddress } from "@nomiclabs/hardhat-ethers/signers" import type { BigNumber, BytesLike, ContractTransaction } from "ethers" import type { Operator } from "./utils/operators" import type { BeaconDkg as DKG } from "../typechain/RandomBeaconStub" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { RandomBeacon, SortitionPool, T, TokenStaking } from "../typechain" const { mineBlocks, mineBlocksTo } = helpers.time @@ -2504,16 +2504,15 @@ describe("RandomBeacon - Group Creation", () => { }) }) - // FIXME: Blocked by https://github.com/defi-wonderland/smock/issues/101 - context.skip("with token staking seize call failure", async () => { - let tokenStakingFake: FakeContract + context("with token staking seize call failure", async () => { + let tokenStakingFake: Mock let tx: Promise before(async () => { await createSnapshot() tokenStakingFake = await fakeTokenStaking(randomBeacon) - tokenStakingFake.seize.reverts("faked function revert") + await tokenStakingFake.seize.reverts("faked function revert") tx = randomBeacon .connect(thirdParty) @@ -2522,8 +2521,6 @@ describe("RandomBeacon - Group Creation", () => { after(async () => { await restoreSnapshot() - - tokenStakingFake.seize.reset() }) it("should succeed", async () => { diff --git a/solidity/random-beacon/test/RandomBeacon.Relay.test.ts b/solidity/random-beacon/test/RandomBeacon.Relay.test.ts index 15286d2ddd..6ee2929264 100644 --- a/solidity/random-beacon/test/RandomBeacon.Relay.test.ts +++ b/solidity/random-beacon/test/RandomBeacon.Relay.test.ts @@ -19,7 +19,7 @@ import { fakeTokenStaking } from "./mocks/staking" import type { Groups } from "../typechain/RandomBeacon" import type { Operator, OperatorID } from "./utils/operators" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "./helpers/mock" import type { RandomBeacon, RandomBeaconStub, @@ -904,24 +904,21 @@ describe("RandomBeacon - Relay", () => { } ) - // FIXME: Blocked by https://github.com/defi-wonderland/smock/issues/101 - context.skip("when token staking seize call fails", async () => { - let tokenStakingFake: FakeContract + context("when token staking seize call fails", async () => { + let tokenStakingFake: Mock let tx: Promise before(async () => { await createSnapshot() tokenStakingFake = await fakeTokenStaking(randomBeacon) - tokenStakingFake.seize.reverts("faked function revert") + await tokenStakingFake.seize.reverts("faked function revert") tx = randomBeacon.reportRelayEntryTimeout(membersIDs) }) after(async () => { await restoreSnapshot() - - tokenStakingFake.seize.reset() }) it("should succeed", async () => { @@ -1042,16 +1039,15 @@ describe("RandomBeacon - Relay", () => { }) }) - // FIXME: Blocked by https://github.com/defi-wonderland/smock/issues/101 - context.skip("when token staking seize call fails", async () => { - let tokenStakingFake: FakeContract + context("when token staking seize call fails", async () => { + let tokenStakingFake: Mock let tx: Promise before(async () => { await createSnapshot() tokenStakingFake = await fakeTokenStaking(randomBeacon) - tokenStakingFake.seize.reverts("faked function revert") + await tokenStakingFake.seize.reverts("faked function revert") const notifierSignature = await bls.sign( notifier.address, @@ -1064,8 +1060,6 @@ describe("RandomBeacon - Relay", () => { after(async () => { await restoreSnapshot() - - tokenStakingFake.seize.reset() }) it("should succeed", async () => { diff --git a/solidity/random-beacon/test/helpers/mock.test.ts b/solidity/random-beacon/test/helpers/mock.test.ts new file mode 100644 index 0000000000..8e3700fb72 --- /dev/null +++ b/solidity/random-beacon/test/helpers/mock.test.ts @@ -0,0 +1,301 @@ +import { ethers } from "hardhat" +import { expect } from "chai" + +import { createMock, expectCalledWith } from "./mock" + +import type { Mock } from "./mock" +import type { IMockTarget, MockTargetConsumer } from "../../typechain" + +describe("MockContract", () => { + let target: Mock + let consumer: MockTargetConsumer + + beforeEach(async () => { + target = await createMock("IMockTarget") + + const factory = await ethers.getContractFactory("MockTargetConsumer") + consumer = (await factory.deploy(target.address)) as MockTargetConsumer + await consumer.deployed() + }) + + describe("view functions reached by STATICCALL", () => { + // The mock records calls, which is a storage write, and a storage write is + // impossible under STATICCALL. If recording were unconditional every + // stubbed view function would revert, so this is the case that decides + // whether a contract-backed mock is viable at all. + + it("answers a stubbed view function", async () => { + await target.readValue.returns(42) + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(42) + }) + + it("answers a stubbed view function from a state-changing caller", async () => { + await target.readValue.returns(99) + + await consumer.cacheValue(7) + + expect(await consumer.lastValue()).to.equal(99) + }) + + it("answers with a struct", async () => { + const owner = ethers.Wallet.createRandom().address + await target.readInfo.returns({ + owner, + createdAt: 1234, + active: true, + }) + + const info = await consumer.readInfoThroughStaticCall(1) + + expect(info.owner).to.equal(owner) + expect(info.createdAt).to.equal(1234) + expect(info.active).to.equal(true) + }) + + it("answers with multiple values", async () => { + await target.readPair.returns([11, 22]) + + const [first, second] = await consumer.readPairThroughStaticCall(1) + + expect(first).to.equal(11) + expect(second).to.equal(22) + }) + + it("returns the zero value when unstubbed", async () => { + // smock answers an unconfigured function with the zero value of its + // return type rather than reverting. Empty returndata would not do: + // Solidity checks returndatasize against the size its ABI expects and + // reverts the caller on a short answer, so the helper installs a + // correctly encoded zero for every function up front. + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + }) + + describe("whenCalledWith", () => { + it("takes precedence over the selector-wide default", async () => { + await target.readValue.returns(1) + await target.readValue.whenCalledWith(7).returns(700) + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(700) + expect(await consumer.readValueThroughStaticCall(8)).to.equal(1) + }) + + it("falls through to the default for non-matching arguments", async () => { + await target.readValue.whenCalledWith(7).returns(700) + + expect(await consumer.readValueThroughStaticCall(8)).to.equal(0) + }) + }) + + describe("reverts", () => { + it("reverts every call to the function", async () => { + await target.doThing.reverts("nope") + + await expect( + consumer.doThing(ethers.constants.AddressZero, 1) + ).to.be.revertedWith("nope") + }) + + it("reverts only the matching arguments", async () => { + const who = ethers.Wallet.createRandom().address + await target.doThing.returns(true) + await target.doThing.whenCalledWith(who, 5).reverts("blocked") + + await expect(consumer.doThing(who, 5)).to.be.revertedWith("blocked") + await expect(consumer.doThing(who, 6)).to.not.be.reverted + }) + }) + + describe("call recording", () => { + it("records arguments of a state-changing call", async () => { + const who = ethers.Wallet.createRandom().address + await target.doThing.returns(true) + + await consumer.doThing(who, 123) + + expect(await target.doThing.callCount()).to.equal(1) + + const call = await target.doThing.getCall(0) + expect(call.args[0]).to.equal(who) + expect(call.args[1]).to.equal(123) + }) + + it("expectCalledWith matches any recorded call, not only a lone one", async () => { + await consumer.noReturn(5) + await consumer.noReturn(6) + + // `calledWith` says nothing about how many calls there were, so both + // recorded calls must satisfy it and a third value must not. + await expectCalledWith(target.noReturn, [5]) + await expectCalledWith(target.noReturn, [6]) + + let failed = false + try { + await expectCalledWith(target.noReturn, [7]) + } catch { + failed = true + } + expect(failed, "expected a non-matching argument to fail").to.equal(true) + }) + + it("records a function that returns nothing", async () => { + await consumer.noReturn(5) + await consumer.noReturn(6) + + expect(await target.noReturn.callCount()).to.equal(2) + expect((await target.noReturn.getCall(1)).args[0]).to.equal(6) + }) + + it("counts each function separately", async () => { + await target.doThing.returns(true) + + await consumer.doThing(ethers.constants.AddressZero, 1) + await consumer.noReturn(1) + + expect(await target.doThing.callCount()).to.equal(1) + expect(await target.noReturn.callCount()).to.equal(1) + }) + }) + + describe("reset", () => { + it("clears recorded calls and configured responses for one function", async () => { + await target.doThing.returns(true) + await consumer.doThing(ethers.constants.AddressZero, 1) + + await target.doThing.reset() + + expect(await target.doThing.callCount()).to.equal(0) + // The configured `true` is gone, so the call answers with empty data. + await consumer.doThing(ethers.constants.AddressZero, 1) + expect(await consumer.lastResult()).to.equal(false) + }) + + it("clears a whenCalledWith entry", async () => { + await target.readValue.whenCalledWith(7).returns(700) + await target.readValue.reset() + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + + it("clears a view function's configuration, which is never recorded", async () => { + // `reset` cannot infer entries from recorded calls, because STATICCALL + // calls are not recorded. It has to track what was configured. + await target.readValue.returns(5) + await consumer.readValueThroughStaticCall(7) + + await target.readValue.reset() + + expect(await consumer.readValueThroughStaticCall(7)).to.equal(0) + }) + + it("leaves other functions alone", async () => { + await target.readValue.returns(5) + await target.doThing.returns(true) + + await target.doThing.reset() + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(5) + }) + + it("clears everything at the mock level", async () => { + await target.readValue.returns(5) + await consumer.noReturn(1) + + await target.reset() + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(0) + expect(await target.noReturn.callCount()).to.equal(0) + }) + }) + + describe("wallet", () => { + it("sends transactions from the mock's own address", async () => { + // smock's `fake.wallet`, used where production code checks + // `msg.sender == someContract`. + const factory = await ethers.getContractFactory("MockTargetConsumer") + const other = await factory.deploy(target.address) + await other.deployed() + + const tx = await other.connect(target.wallet).noReturn(1) + const receipt = await tx.wait() + + expect(receipt.from).to.equal(target.address) + }) + }) + + describe("address option", () => { + it("deploys at a requested address", async () => { + const address = ethers.utils.getAddress( + `0x${"ab".repeat(20)}`.toLowerCase() + ) + + const pinned = await createMock("IMockTarget", { address }) + + expect(pinned.address).to.equal(address) + await pinned.readValue.returns(7) + + const factory = await ethers.getContractFactory("MockTargetConsumer") + const pinnedConsumer = await factory.deploy(address) + await pinnedConsumer.deployed() + + expect(await pinnedConsumer.readValueThroughStaticCall(1)).to.equal(7) + }) + }) + + describe("storage left behind at a pinned address", () => { + it("is not read back as configuration", async () => { + // Mocks are routinely installed over an address that already holds a + // deployed contract — `test/fixtures/bridge.ts` pins them at the Bridge's + // real ecdsaWalletRegistry and relay. `hardhat_setCode` replaces the code + // and leaves the storage, so a mock keeping its state at slots 0, 1, 2... + // would read that leftover as its own. + const address = ethers.utils.getAddress(`0x${"cd".repeat(20)}`) + const garbage = + "0xdeadbeef00000000000000000000000000000000000000000000000000000001" + + await Promise.all( + Array.from({ length: 8 }, (_, slot) => + ethers.provider.send("hardhat_setStorageAt", [ + address, + ethers.utils.hexValue(slot), + garbage, + ]) + ) + ) + + const pinned = await createMock("IMockTarget", { address }) + + expect(await pinned.doThing.callCount()).to.equal(0) + + await pinned.readValue.returns(7) + + const factory = await ethers.getContractFactory("MockTargetConsumer") + const pinnedConsumer = await factory.deploy(address) + await pinnedConsumer.deployed() + + expect(await pinnedConsumer.readValueThroughStaticCall(1)).to.equal(7) + }) + }) + + describe("call assertions on read-only functions", () => { + it("are refused rather than silently answered zero", async () => { + // A view function is reached by STATICCALL and cannot be recorded. + // Answering "0 calls" would read as a passing assertion. + let message = "" + try { + await target.readValue.callCount() + } catch (error) { + message = (error as Error).message + } + + expect(message).to.contain("STATICCALL") + }) + + it("still allow the function to be stubbed", async () => { + await target.readValue.returns(3) + + expect(await consumer.readValueThroughStaticCall(1)).to.equal(3) + }) + }) +}) diff --git a/solidity/random-beacon/test/helpers/mock.ts b/solidity/random-beacon/test/helpers/mock.ts new file mode 100644 index 0000000000..ec2e8d75eb --- /dev/null +++ b/solidity/random-beacon/test/helpers/mock.ts @@ -0,0 +1,684 @@ +/* + * The `__mock__*` names below are the administrative entry points of + * `MockContract.sol`, deliberately namespaced there so they cannot be confused + * with — or collide with — a function of the interface being mocked. They are + * dictated by the contract, so the dangling-underscore rule does not apply. + */ +/* eslint-disable no-underscore-dangle */ +import { ethers, artifacts } from "hardhat" +import { expect } from "chai" +import { BigNumber } from "ethers" + +import type { BigNumberish, Contract, Signer } from "ethers" +import type { FunctionFragment, Interface, ParamType } from "ethers/lib/utils" + +/** + * Programmable contract mock, replacing `@defi-wonderland/smock`. + * + * smock configured its fakes by mutating in-process JavaScript state inside + * Hardhat's EVM, which is why its API was synchronous — and also why it broke + * on Hardhat >= 2.20 and is archived upstream with no forward path. This + * replacement drives an ordinary deployed contract (`MockContract.sol`) + * over the public provider API, so it depends on nothing Hardhat can change + * underneath it. + * + * The consequence is that configuration is a transaction, so every setup and + * inspection call here returns a promise and must be awaited. That is the one + * behavioural difference from smock and the reason the migration touches call + * sites at all. + * + * A transaction also mines a block, and Hardhat advances the clock a second + * per block, where smock advanced it not at all. Suites that assert on a + * boundary cannot absorb that: WalletProposalValidator stubs a 7200 second + * delay, advances time by exactly 7200 and requires + * `block.timestamp > requestedAt + minAge` to be false — any drift inverts it. + * So every write below pins the next block's timestamp to the current one, + * which needs `allowBlocksWithSameTimestamp` in hardhat.config.ts and is why + * this package is on hardhat >= 2.19. + * + * Semantics follow Foundry's `vm.mockCall` deliberately: an exact-calldata + * entry wins over a selector-wide default, and an unconfigured function + * returns the zero value of its return type, matching smock. + * + * That last part is not free. Solidity compares returndatasize against the + * size its ABI expects and reverts on a short answer, so a mock cannot answer + * an unconfigured function with empty data — it has to return a correctly + * encoded zero. Only this helper knows the mocked ABI, so it computes those + * encodings up front and installs them in the mock as a base layer that + * `reset` does not disturb. + */ + +/** + * Runs a configuration transaction without advancing the chain clock. + * + * `allowBlocksWithSameTimestamp` only *permits* a block to reuse the previous + * timestamp; it does not make it happen. Determinism comes from setting the + * next timestamp explicitly, so that is done here rather than left to how fast + * the machine happens to be. + */ +async function withoutAdvancingTime(write: () => Promise): Promise { + const { timestamp } = await ethers.provider.getBlock("latest") + await ethers.provider.send("evm_setNextBlockTimestamp", [timestamp]) + return write() +} + +/** A recorded call, decoded against the mocked interface. */ +export interface MockCall { + /** Decoded arguments, in declaration order. */ + args: unknown[] + /** `msg.value` the call carried, as smock's `getCall(n).value` did. */ + value: BigNumber +} + +/** Configuration and inspection handle for one function of a mock. */ +export interface MockedFunction { + /** + * Answers every call to this function with `value`, unless a + * `whenCalledWith` entry matches first. Call with no argument for a function + * that returns nothing. + */ + returns(value?: unknown): Promise + /** Makes every call to this function revert. */ + reverts(reason?: string): Promise + /** Narrows the next `returns`/`reverts` to one exact argument list. */ + whenCalledWith(...args: unknown[]): { + returns(value?: unknown): Promise + reverts(reason?: string): Promise + } + /** Drops every response configured for this function and every call recorded for it. */ + reset(): Promise + /** Number of calls recorded for this function. */ + callCount(): Promise + /** The i-th recorded call, decoded. */ + getCall(index: number): Promise + /** Every recorded call, decoded. */ + getCalls(): Promise +} + +export type Mock = { + [K in keyof T]: T[K] extends (...args: never[]) => unknown + ? T[K] & MockedFunction + : T[K] +} & { + address: string + /** Signer that sends from the mock's own address, as smock's `fake.wallet` did. */ + wallet: Signer + /** Underlying deployed `MockContract`, for anything this helper does not wrap. */ + mockContract: Contract + /** + * The mocked interface bound to `signer`, as smock's `FakeContract.connect` + * was. smock's fake extended `ethers.Contract` and inherited this; the proxy + * here resolves only the mocked ABI and the keys above, so without it + * `mock.connect(someone)` is `undefined`. + */ + connect(signer: Signer): Contract + /** Drops all configured responses and all recorded calls. */ + reset(): Promise + /** + * Turns call recording on or off. + * + * Recording costs real gas — smock zeroed gas for faked calls, this mock + * SSTOREs the calldata — so a test asserting on the gas of the contract + * under test should switch it off first, or it measures the mock too. + */ + setRecording(enabled: boolean): Promise +} + +/** Selectors of `MockContract`'s own administrative entry points. */ +function adminSelectors(mockInterface: Interface): Set { + return new Set( + Object.keys(mockInterface.functions) + .filter((signature) => signature.startsWith("__mock__")) + .map((signature) => mockInterface.getSighash(signature)) + ) +} + +/** + * `MockContract` answers the mocked interface through its fallback, so its own + * `__mock__*` entry points share the selector space with whatever is being + * mocked. A four-byte collision would silently shadow a real function, which + * would be a very confusing test failure. It cannot happen by accident, but it + * costs nothing to prove rather than assume. + */ +function assertNoSelectorCollision( + target: Interface, + mockInterface: Interface, + targetName: string +): void { + const reserved = adminSelectors(mockInterface) + + Object.keys(target.functions).forEach((signature) => { + const selector = target.getSighash(signature) + if (reserved.has(selector)) { + throw new Error( + `${targetName}.${signature} has selector ${selector}, which collides ` + + "with a MockContract administrative function. This mock cannot " + + "represent that interface." + ) + } + }) +} + +function fragmentsByName(target: Interface): Map { + const byName = new Map() + + Object.values(target.functions).forEach((fragment) => { + const existing = byName.get(fragment.name) + if (existing) { + existing.push(fragment) + } else { + byName.set(fragment.name, [fragment]) + } + }) + + return byName +} + +/** + * Picks the fragment to use for a name. Overloads are ambiguous by name alone; + * rather than guess, fail loudly and let the caller address the mock's + * `mockContract` directly. + */ +function resolveFragment( + fragments: FunctionFragment[], + name: string, + targetName: string +): FunctionFragment { + if (fragments.length > 1) { + throw new Error( + `${targetName}.${name} is overloaded (${fragments.length} signatures). ` + + "Address it through the mock's mockContract handle instead." + ) + } + + return fragments[0] +} + +/** + * The zero value of an ABI type, shaped the way `defaultAbiCoder` wants it. + * + * Dynamic types cannot be zero-filled word-wise, and tuples have to be built + * component by component, so this walks the type rather than assuming a flat + * layout. + */ +function zeroValueFor(type: ParamType): unknown { + if (type.baseType === "array") { + if (type.arrayLength === -1) { + return [] + } + return Array.from({ length: type.arrayLength }, () => + zeroValueFor(type.arrayChildren) + ) + } + + if (type.baseType === "tuple") { + return type.components.map((component) => zeroValueFor(component)) + } + + if (type.baseType === "address") { + return ethers.constants.AddressZero + } + + if (type.baseType === "bool") { + return false + } + + if (type.baseType === "string") { + return "" + } + + if (type.baseType === "bytes") { + return "0x" + } + + const fixedBytes = /^bytes(\d+)$/.exec(type.baseType) + if (fixedBytes) { + return `0x${"00".repeat(Number(fixedBytes[1]))}` + } + + // Everything left is uint*/int*. + return 0 +} + +/** + * Shapes a configured return value the way `defaultAbiCoder` wants it. + * + * smock accepted a named object for a multi-output function — Bridge's + * `depositParameters` returns four values and is configured as + * `{ depositDustThreshold, depositTreasuryFeeDivisor, ... }`. The coder wants + * those positionally, so they are mapped back by output name. A single-output + * function is different: an object there is a struct, and the coder handles it. + */ +function toPositional(outputs: ParamType[], value: unknown): unknown[] { + if (outputs.length === 1) { + return [value] + } + + if (Array.isArray(value)) { + return value + } + + if (value !== null && typeof value === "object") { + const named = value as Record + return outputs.map((output, index) => + output.name && output.name in named ? named[output.name] : named[index] + ) + } + + return [value] +} + +function encodeReturn(fragment: FunctionFragment, value: unknown): string { + if (fragment.outputs === null || fragment.outputs.length === 0) { + return "0x" + } + + return ethers.utils.defaultAbiCoder.encode( + fragment.outputs, + toPositional(fragment.outputs, value) + ) +} + +function encodeRevert(reason?: string): string { + if (reason === undefined) { + return "0x" + } + + return ( + ethers.utils.id("Error(string)").slice(0, 10) + + ethers.utils.defaultAbiCoder.encode(["string"], [reason]).slice(2) + ) +} + +/** + * Deploys a programmable mock answering `target`'s interface. + * + * @param target Name of the contract or interface to mock, as passed to + * `artifacts.readArtifact` — e.g. `"IBridge"`. + * @param options.address Deploy the mock at this exact address, replacing + * whatever is there. Mirrors smock's `{ address }` option. + * @returns A handle exposing each of `target`'s functions with `returns`, + * `whenCalledWith`, `reverts`, `reset`, `callCount` and `getCall`. + */ +export async function createMock( + target: string, + options: { address?: string } = {} +): Promise> { + const targetArtifact = await artifacts.readArtifact(target) + const targetInterface = new ethers.utils.Interface(targetArtifact.abi) + + const mockFactory = await ethers.getContractFactory("MockContract") + // Deploying is a transaction too, and a mock is routinely created inside a + // `before` hook after the test has already captured a baseline timestamp. + let mockContract = await withoutAdvancingTime(() => mockFactory.deploy()) + await mockContract.deployed() + + assertNoSelectorCollision(targetInterface, mockContract.interface, target) + + if (options.address !== undefined) { + // Move the deployed bytecode to the requested address, before anything is + // configured. `hardhat_setCode` copies code and not storage, and every + // configuration below — the base returns, the non-recording flags, and + // later every `returns`/`whenCalledWith` — is storage. Configuring first + // and relocating afterwards left a pinned mock with none of it. + const code = await ethers.provider.getCode(mockContract.address) + await ethers.provider.send("hardhat_setCode", [options.address, code]) + mockContract = mockContract.attach(options.address) + } + + // Install the response of last resort for every function, so an unstubbed + // one answers with a correctly sized zero instead of reverting the caller. + const baseFragments = Object.values(targetInterface.functions) + const baseSelectors = baseFragments.map((fragment) => + targetInterface.getSighash(fragment) + ) + const baseReturns = baseFragments.map((fragment) => + fragment.outputs === null || fragment.outputs.length === 0 + ? "0x" + : ethers.utils.defaultAbiCoder.encode( + fragment.outputs, + fragment.outputs.map((output) => zeroValueFor(output)) + ) + ) + await withoutAdvancingTime(() => + mockContract.__mock__setBaseReturns(baseSelectors, baseReturns) + ) + + // Flag the read-only functions. Solidity reaches them by STATICCALL, where + // the storage write recording needs is impossible, so the mock must not even + // attempt it. Doing this from the ABI rather than discovering it at runtime + // also lets `callCount`/`getCall` refuse loudly below. + const nonRecordingSelectors = baseFragments + .filter( + (fragment) => + fragment.stateMutability === "view" || + fragment.stateMutability === "pure" + ) + .map((fragment) => targetInterface.getSighash(fragment)) + if (nonRecordingSelectors.length > 0) { + await withoutAdvancingTime(() => + mockContract.__mock__setNonRecordingSelectors(nonRecordingSelectors) + ) + } + + await ethers.provider.send("hardhat_impersonateAccount", [ + mockContract.address, + ]) + await ethers.provider.send("hardhat_setBalance", [ + mockContract.address, + "0x21e19e0c9bab2400000", // 10_000 ETH, so the mock can pay for its own sends + ]) + const wallet = await ethers.getSigner(mockContract.address) + + const byName = fragmentsByName(targetInterface) + + function buildFunction(name: string): MockedFunction { + const fragment = resolveFragment(byName.get(name) ?? [], name, target) + const selector = targetInterface.getSighash(fragment) + const readOnly = + fragment.stateMutability === "view" || fragment.stateMutability === "pure" + + // Calls to a read-only function cannot be recorded, so answering "0 calls" + // would be a lie that reads as a passing assertion. Refuse instead. + const refuseIfReadOnly = () => { + if (readOnly) { + throw new Error( + `${target}.${name} is ${fragment.stateMutability}, so Solidity ` + + "reaches it by STATICCALL and the mock cannot record the call. " + + "Assert on the state-changing function that consumed the value " + + "instead." + ) + } + } + + const setForCalldata = async ( + args: unknown[], + behaviour: "return" | "revert", + payload: unknown + ): Promise => { + let callData: string + try { + callData = targetInterface.encodeFunctionData(fragment, args as never[]) + } catch (error) { + // smock stored `whenCalledWith` arguments as JavaScript values and + // compared them after decoding, so arguments that are not valid for + // the signature simply never matched and the call fell through to the + // selector-wide default. Encoding them up front turns the same + // situation into a thrown error, which would fail tests that pass + // today for reasons unrelated to this migration — a value converted to + // a Wormhole address twice, for instance, is 44 bytes where the + // signature wants 32. + // + // Keep smock's outcome — an entry that can never match — but say so, + // because it does mean the narrowing in that test is dead. + // eslint-disable-next-line no-console + console.warn( + `mock: ${target}.${name} whenCalledWith(...) arguments cannot be ` + + "encoded for this signature, so the entry can never match and " + + `is being skipped: ${(error as Error).message.split("(")[0].trim()}` + ) + return + } + + await withoutAdvancingTime(() => + behaviour === "return" + ? mockContract.__mock__setReturnForCalldata( + callData, + encodeReturn(fragment, payload) + ) + : mockContract.__mock__setRevertForCalldata( + callData, + encodeRevert(payload as string | undefined) + ) + ) + } + + const decodeCall = (callData: string, value: BigNumber): MockCall => ({ + args: Array.from( + targetInterface.decodeFunctionData(fragment, callData) + ) as unknown[], + value, + }) + + return { + async returns(value?: unknown): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setReturnForSelector( + selector, + encodeReturn(fragment, value) + ) + ) + }, + + async reverts(reason?: string): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setRevertForSelector( + selector, + encodeRevert(reason) + ) + ) + }, + + whenCalledWith(...args: unknown[]) { + return { + returns: (value?: unknown) => setForCalldata(args, "return", value), + reverts: (reason?: string) => setForCalldata(args, "revert", reason), + } + }, + + async reset(): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__resetSelector(selector) + ) + }, + + async callCount(): Promise { + refuseIfReadOnly() + + const count: BigNumberish = + await mockContract.__mock__callCountForSelector(selector) + return Number(count) + }, + + async getCall(index: number): Promise { + refuseIfReadOnly() + + const [callData, value] = await Promise.all([ + mockContract.__mock__callForSelectorAt(selector, index), + mockContract.__mock__callValueForSelectorAt(selector, index), + ]) + return decodeCall(callData as string, value as BigNumber) + }, + + async getCalls(): Promise { + refuseIfReadOnly() + + const count: BigNumberish = + await mockContract.__mock__callCountForSelector(selector) + const calls: MockCall[] = [] + + for (let i = 0; i < Number(count); i++) { + // Sequential on purpose: ordering is the point of this accessor. + // eslint-disable-next-line no-await-in-loop + const [callData, value] = await Promise.all([ + mockContract.__mock__callForSelectorAt(selector, i), + mockContract.__mock__callValueForSelectorAt(selector, i), + ]) + calls.push(decodeCall(callData as string, value as BigNumber)) + } + + return calls + }, + } + } + + const functions = new Map() + const readContract = new ethers.Contract( + mockContract.address, + targetArtifact.abi, + ethers.provider + ) + + const handle = { + address: mockContract.address, + wallet, + mockContract, + connect(signer: Signer): Contract { + return new ethers.Contract( + mockContract.address, + targetArtifact.abi, + signer + ) + }, + async reset(): Promise { + await withoutAdvancingTime(() => mockContract.__mock__reset()) + }, + async setRecording(enabled: boolean): Promise { + await withoutAdvancingTime(() => + mockContract.__mock__setRecording(enabled) + ) + }, + } + + return new Proxy(handle, { + get(base, property: string | symbol, receiver) { + if (typeof property !== "string" || property in base) { + return Reflect.get(base, property, receiver) + } + + if (!byName.has(property)) { + return Reflect.get(base, property, receiver) + } + + if (!functions.has(property)) { + // Callable like the contract itself, so a test can read through the + // mock, with the configuration surface hung off the same object — + // which is the shape smock's fakes had. + const callable = (...args: unknown[]) => readContract[property](...args) + functions.set( + property, + Object.assign(callable, buildFunction(property)) as MockedFunction + ) + } + + return functions.get(property) + }, + }) as unknown as Mock +} + +/** + * Assertion helpers replacing smock's chai matchers. + * + * smock's `expect(fake.fn).to.have.been.calledOnce` worked because the call log + * was in-process JavaScript, so a chai *property* could read it synchronously. + * Here the log is on chain, so reading it is asynchronous, and a property + * cannot be awaited — `await expect(x).to.have.been.calledOnce` would await the + * assertion object, not the count, and pass unconditionally. These are + * functions so that the `await` is real. + */ + +async function counted(fn: MockedFunction): Promise { + return fn.callCount() +} + +/** `expect(fake.fn).to.have.been.called` */ +export async function expectCalled(fn: MockedFunction): Promise { + const count = await counted(fn) + expect(count, "expected the function to have been called").to.be.greaterThan( + 0 + ) +} + +/** `expect(fake.fn).to.have.been.calledOnce` */ +export async function expectCalledOnce(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly one call").to.equal(1) +} + +/** `expect(fake.fn).to.not.have.been.called` */ +export async function expectNotCalled(fn: MockedFunction): Promise { + expect(await counted(fn), "expected no calls").to.equal(0) +} + +/** `expect(fake.fn).to.have.been.calledThrice` */ +export async function expectCalledThrice(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly three calls").to.equal(3) +} + +/** `expect(fake.fn).to.have.been.calledTwice` */ +export async function expectCalledTwice(fn: MockedFunction): Promise { + expect(await counted(fn), "expected exactly two calls").to.equal(2) +} + +/** `expect(fake.fn).to.have.been.calledOnceWith(...args)` */ +/** + * Puts one recorded or expected argument into a comparable form. + * + * The two sides never arrive in the same representation. ethers decodes an ABI + * integer to a `BigNumber` above 48 bits and to a plain `number` at or below + * it, so a `uint256` argument reaches this as a `BigNumber` while the + * `uint32` getter the test compared it against yields a `number`; and a struct + * or dynamic array puts both one level down, where the previous top-level-only + * check never looked. smock compared `BigNumberish` values numerically at any + * depth, so both shapes used to pass. + * + * Numerics are wrapped rather than rendered bare, so that a genuine string + * argument of `"100"` still fails against a numeric `100`. Everything else — + * addresses, bytes, booleans — is left exactly as it came, because for those + * the two sides already agree and loosening the comparison would only hide a + * real mismatch. + */ +function normalizeForComparison(value: unknown): unknown { + if (BigNumber.isBigNumber(value)) { + return { numeric: value.toString() } + } + if (typeof value === "number" || typeof value === "bigint") { + return { numeric: value.toString() } + } + if (Array.isArray(value)) { + return value.map(normalizeForComparison) + } + return value +} + +export async function expectCalledOnceWith( + fn: MockedFunction, + args: unknown[] +): Promise { + expect(await counted(fn), "expected exactly one call").to.equal(1) + + const call = await fn.getCall(0) + + expect(call.args.length, "argument count").to.equal(args.length) + args.forEach((expected, index) => { + const actual = call.args[index] + expect(normalizeForComparison(actual), `argument ${index}`).to.deep.equal( + normalizeForComparison(expected) + ) + }) +} + +/** + * `expect(fake.fn).to.have.been.calledWith(...)` — some recorded call matched. + * + * Deliberately weaker than `expectCalledOnceWith`: smock's `calledWith` says + * nothing about how many times the function ran, so translating those sites to + * the `Once` variant would quietly add an assertion the test never made. + */ +export async function expectCalledWith( + fn: MockedFunction, + args: unknown[] +): Promise { + const calls = await fn.getCalls() + + expect(calls.length, "expected at least one call").to.be.greaterThan(0) + + const wanted = args.map(normalizeForComparison) + const seen = calls.map((call) => call.args.map(normalizeForComparison)) + + expect( + seen, + `expected a call with ${calls.length} recorded, none matching` + ).to.deep.include(wanted) +} + +export default createMock diff --git a/solidity/random-beacon/test/mocks/staking.ts b/solidity/random-beacon/test/mocks/staking.ts index ab4d053cbb..8475883d16 100644 --- a/solidity/random-beacon/test/mocks/staking.ts +++ b/solidity/random-beacon/test/mocks/staking.ts @@ -1,13 +1,13 @@ -import { smock } from "@defi-wonderland/smock" +import { createMock } from "../helpers/mock" -import type { FakeContract } from "@defi-wonderland/smock" +import type { Mock } from "../helpers/mock" import type { RandomBeacon, TokenStaking } from "../../typechain" // eslint-disable-next-line import/prefer-default-export export async function fakeTokenStaking( randomBeacon: RandomBeacon -): Promise> { - const tokenStaking = await smock.fake("TokenStaking", { +): Promise> { + const tokenStaking = await createMock("TokenStaking", { address: await randomBeacon.callStatic.staking(), }) diff --git a/solidity/random-beacon/yarn.lock b/solidity/random-beacon/yarn.lock index 8e2b2a60ce..1143d93108 100644 --- a/solidity/random-beacon/yarn.lock +++ b/solidity/random-beacon/yarn.lock @@ -136,29 +136,6 @@ __metadata: languageName: node linkType: hard -"@defi-wonderland/smock@npm:2.3.4": - version: 2.3.4 - resolution: "@defi-wonderland/smock@npm:2.3.4" - dependencies: - "@nomicfoundation/ethereumjs-evm": "npm:^1.0.0-rc.3" - "@nomicfoundation/ethereumjs-util": "npm:^8.0.0-rc.3" - "@nomicfoundation/ethereumjs-vm": "npm:^6.0.0-rc.3" - diff: "npm:^5.0.0" - lodash.isequal: "npm:^4.5.0" - lodash.isequalwith: "npm:^4.4.0" - rxjs: "npm:^7.2.0" - semver: "npm:^7.3.5" - peerDependencies: - "@ethersproject/abi": ^5 - "@ethersproject/abstract-provider": ^5 - "@ethersproject/abstract-signer": ^5 - "@nomiclabs/hardhat-ethers": ^2 - ethers: ^5 - hardhat: ^2 - checksum: 10c0/3181d6881d9447fed6afcd7e69307e2d314a3e3b35384ccfe31b0979c53be25864d74644daa16378d2273efde3b50917b5f81149ea9fd828b5da3cd022b85c42 - languageName: node - linkType: hard - "@ensdomains/ens@npm:^0.4.4": version: 0.4.5 resolution: "@ensdomains/ens@npm:0.4.5" @@ -1577,7 +1554,6 @@ __metadata: version: 0.0.0-use.local resolution: "@keep-network/random-beacon@workspace:." dependencies: - "@defi-wonderland/smock": "npm:2.3.4" "@keep-network/hardhat-helpers": "npm:^0.6.0-pre.15" "@keep-network/hardhat-local-networks-config": "npm:^0.1.0-pre.0" "@keep-network/sortition-pools": "npm:^2.0.0-pre.16" @@ -1728,20 +1704,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-block@npm:4.2.2": - version: 4.2.2 - resolution: "@nomicfoundation/ethereumjs-block@npm:4.2.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-tx": "npm:4.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/1c211294b3064d2bbfcf33b460438f01fb9cd77429314a90a5e2ffce5162019a384f4ae7d3825cfd386a140db191b251b475427562c53f85beffc786156f817e - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-block@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-block@npm:5.0.2" @@ -1757,26 +1719,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-blockchain@npm:6.2.2": - version: 6.2.2 - resolution: "@nomicfoundation/ethereumjs-blockchain@npm:6.2.2" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-ethash": "npm:2.0.5" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - abstract-level: "npm:^1.0.3" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - level: "npm:^8.0.0" - lru-cache: "npm:^5.1.1" - memory-level: "npm:^1.0.0" - checksum: 10c0/6fe6e315900e1d6a29d59be41f566bdfd5ffdf82ab0fe081b1999dcc4eec3a248ab080d359a56e8cde4e473ca90349b0c50fa1ab707aa3e275fb1c478237e5e2 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-blockchain@npm:7.0.2": version: 7.0.2 resolution: "@nomicfoundation/ethereumjs-blockchain@npm:7.0.2" @@ -1798,16 +1740,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-common@npm:3.1.2": - version: 3.1.2 - resolution: "@nomicfoundation/ethereumjs-common@npm:3.1.2" - dependencies: - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - crc-32: "npm:^1.2.0" - checksum: 10c0/90910630025b5bb503f36125c45395cc9f875ffdd8137a83e9c1d566678edcc8db40f8ce1dff9da1ef2c91c7d6b6d1fa75c41a9579a5d3a8f0ae669fcea244b1 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-common@npm:4.0.2": version: 4.0.2 resolution: "@nomicfoundation/ethereumjs-common@npm:4.0.2" @@ -1818,20 +1750,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-ethash@npm:2.0.5": - version: 2.0.5 - resolution: "@nomicfoundation/ethereumjs-ethash@npm:2.0.5" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - abstract-level: "npm:^1.0.3" - bigint-crypto-utils: "npm:^3.0.23" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/7a90ef53ae4c1ac5a314c3447966fdbefcc96481ae3a05d59e881053350c55be7c841708c61c79a2af40bbb0181d6e0db42601592f17a4db1611b199d49e8544 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-ethash@npm:3.0.2": version: 3.0.2 resolution: "@nomicfoundation/ethereumjs-ethash@npm:3.0.2" @@ -1846,22 +1764,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-evm@npm:1.3.2, @nomicfoundation/ethereumjs-evm@npm:^1.0.0-rc.3": - version: 1.3.2 - resolution: "@nomicfoundation/ethereumjs-evm@npm:1.3.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - "@types/async-eventemitter": "npm:^0.2.1" - async-eventemitter: "npm:^0.2.4" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - mcl-wasm: "npm:^0.7.1" - rustbn.js: "npm:~0.2.0" - checksum: 10c0/4aa14d7dce597a91c25bec5975022348741cebf6ed20cda028ddcbebe739ba2e6f4c879fa1ebe849bd5c78d3fd2443ebbb7d57e1fca5a98fbe88fc9ce15d9fd6 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-evm@npm:2.0.2": version: 2.0.2 resolution: "@nomicfoundation/ethereumjs-evm@npm:2.0.2" @@ -1878,15 +1780,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-rlp@npm:4.0.3": - version: 4.0.3 - resolution: "@nomicfoundation/ethereumjs-rlp@npm:4.0.3" - bin: - rlp: bin/rlp - checksum: 10c0/3e3c07abf53ff5832afbbdf3f3687e11e2e829699348eea1ae465084c72e024559d97e351e8f0fb27f32c7896633c7dd50b19d8de486e89cde777fd5447381cd - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-rlp@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-rlp@npm:5.0.2" @@ -1896,21 +1789,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-statemanager@npm:1.0.5": - version: 1.0.5 - resolution: "@nomicfoundation/ethereumjs-statemanager@npm:1.0.5" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - functional-red-black-tree: "npm:^1.0.1" - checksum: 10c0/4a05b7a86a1bbc8fd409416edf437d99d9d4498c438e086c30250cb3dc92ff00086f9ff959f469c72d46178e831104dc15f10465e27fbbf0ca97da27d6889a0c - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-statemanager@npm:2.0.2": version: 2.0.2 resolution: "@nomicfoundation/ethereumjs-statemanager@npm:2.0.2" @@ -1925,18 +1803,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-trie@npm:5.0.5": - version: 5.0.5 - resolution: "@nomicfoundation/ethereumjs-trie@npm:5.0.5" - dependencies: - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - readable-stream: "npm:^3.6.0" - checksum: 10c0/cab544fef4bcdc3acef1bfb4ee9f2fde44b66a22b2329bfd67515facdf115a318961f8bc0e38befded838e8fc513974f90f340b53a98b8469e54960b15cd857a - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-trie@npm:6.0.2": version: 6.0.2 resolution: "@nomicfoundation/ethereumjs-trie@npm:6.0.2" @@ -1950,18 +1816,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-tx@npm:4.1.2": - version: 4.1.2 - resolution: "@nomicfoundation/ethereumjs-tx@npm:4.1.2" - dependencies: - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/cb569c882d3ce922acff1a4238864f11109ac5a30dfa481b1ed9c7043c2b773f3a5fc88a3f4fefb62b11c448305296533f555f93d1d969a5abd3c2a13c80ed74 - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-tx@npm:5.0.2": version: 5.0.2 resolution: "@nomicfoundation/ethereumjs-tx@npm:5.0.2" @@ -1976,16 +1830,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-util@npm:8.0.6, @nomicfoundation/ethereumjs-util@npm:^8.0.0-rc.3": - version: 8.0.6 - resolution: "@nomicfoundation/ethereumjs-util@npm:8.0.6" - dependencies: - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - ethereum-cryptography: "npm:0.1.3" - checksum: 10c0/647006f4dfa962f61cec54c34ff9939468042cf762ff3b2cf80c8362558f21750348a3cda63dc9890b1cb2ba664f97dc4a892afca5f5d6f95b3ba4d56be5a33b - languageName: node - linkType: hard - "@nomicfoundation/ethereumjs-util@npm:9.0.2": version: 9.0.2 resolution: "@nomicfoundation/ethereumjs-util@npm:9.0.2" @@ -2018,30 +1862,6 @@ __metadata: languageName: node linkType: hard -"@nomicfoundation/ethereumjs-vm@npm:^6.0.0-rc.3": - version: 6.4.2 - resolution: "@nomicfoundation/ethereumjs-vm@npm:6.4.2" - dependencies: - "@nomicfoundation/ethereumjs-block": "npm:4.2.2" - "@nomicfoundation/ethereumjs-blockchain": "npm:6.2.2" - "@nomicfoundation/ethereumjs-common": "npm:3.1.2" - "@nomicfoundation/ethereumjs-evm": "npm:1.3.2" - "@nomicfoundation/ethereumjs-rlp": "npm:4.0.3" - "@nomicfoundation/ethereumjs-statemanager": "npm:1.0.5" - "@nomicfoundation/ethereumjs-trie": "npm:5.0.5" - "@nomicfoundation/ethereumjs-tx": "npm:4.1.2" - "@nomicfoundation/ethereumjs-util": "npm:8.0.6" - "@types/async-eventemitter": "npm:^0.2.1" - async-eventemitter: "npm:^0.2.4" - debug: "npm:^4.3.3" - ethereum-cryptography: "npm:0.1.3" - functional-red-black-tree: "npm:^1.0.1" - mcl-wasm: "npm:^0.7.1" - rustbn.js: "npm:~0.2.0" - checksum: 10c0/78e4b0ba20e8fa4ef112bae88f432746647ed48b41918b34855fe08269be3aaff84f95c08b6c61475fb70f24a28ba73612bd2bcd19b3c007c8bf9e11a43fa8e0 - languageName: node - linkType: hard - "@nomicfoundation/solidity-analyzer-darwin-arm64@npm:0.1.2": version: 0.1.2 resolution: "@nomicfoundation/solidity-analyzer-darwin-arm64@npm:0.1.2" @@ -2664,15 +2484,6 @@ __metadata: languageName: node linkType: hard -"@types/async-eventemitter@npm:^0.2.1": - version: 0.2.4 - resolution: "@types/async-eventemitter@npm:0.2.4" - dependencies: - "@types/events": "npm:*" - checksum: 10c0/2ae267eb3e959fe5aaf6d850ab06ac2e5b44f1a7e3e421250f3ebaa8a108f641e9050d042980bc35aab98d6fa5f1a62a43cfb7f377011ce9013ed62229327111 - languageName: node - linkType: hard - "@types/bn.js@npm:*, @types/bn.js@npm:^5.1.0": version: 5.1.0 resolution: "@types/bn.js@npm:5.1.0" @@ -2698,13 +2509,6 @@ __metadata: languageName: node linkType: hard -"@types/events@npm:*": - version: 3.0.3 - resolution: "@types/events@npm:3.0.3" - checksum: 10c0/3a56f8c51eb4ebc0d05dcadca0c6636c816acc10216ce36c976fad11e54a01f4bb979a07211355686015884753b37f17d74bfdc7aaf4ebb027c1e8a501c7b21d - languageName: node - linkType: hard - "@types/json-schema@npm:^7.0.7": version: 7.0.9 resolution: "@types/json-schema@npm:7.0.9" @@ -3546,7 +3350,7 @@ __metadata: languageName: node linkType: hard -"async-eventemitter@npm:^0.2.2, async-eventemitter@npm:^0.2.4": +"async-eventemitter@npm:^0.2.2": version: 0.2.4 resolution: "async-eventemitter@npm:0.2.4" dependencies: @@ -5947,7 +5751,7 @@ __metadata: languageName: node linkType: hard -"diff@npm:5.0.0, diff@npm:^5.0.0": +"diff@npm:5.0.0": version: 5.0.0 resolution: "diff@npm:5.0.0" checksum: 10c0/08c5904779bbababcd31f1707657b1ad57f8a9b65e6f88d3fb501d09a965d5f8d73066898a7d3f35981f9e4101892c61d99175d421f3b759533213c253d91134 @@ -10280,20 +10084,6 @@ __metadata: languageName: node linkType: hard -"lodash.isequal@npm:^4.5.0": - version: 4.5.0 - resolution: "lodash.isequal@npm:4.5.0" - checksum: 10c0/dfdb2356db19631a4b445d5f37868a095e2402292d59539a987f134a8778c62a2810c2452d11ae9e6dcac71fc9de40a6fedcb20e2952a15b431ad8b29e50e28f - languageName: node - linkType: hard - -"lodash.isequalwith@npm:^4.4.0": - version: 4.4.0 - resolution: "lodash.isequalwith@npm:4.4.0" - checksum: 10c0/edb7f01c6d949fad36c756e7b1af6ee1df8b9663cee62880186a3b241e133a981bc7eed42cf14715a58f939d6d779185c3ead0c3f0d617d1ad59f50b423eb5d5 - languageName: node - linkType: hard - "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -12920,15 +12710,6 @@ __metadata: languageName: node linkType: hard -"rxjs@npm:^7.2.0": - version: 7.5.4 - resolution: "rxjs@npm:7.5.4" - dependencies: - tslib: "npm:^2.1.0" - checksum: 10c0/7d40fcfac255e9aa9eaf4175910f27954a4b5cbd53f2031f8babb6e12f09431d8a9147b2d7461b0d0f263e68d68a7160d6c55af26e68d738c05eeb421ee5b2d3 - languageName: node - linkType: hard - "safe-buffer@npm:5.2.1, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" @@ -14331,13 +14112,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.1.0": - version: 2.3.1 - resolution: "tslib@npm:2.3.1" - checksum: 10c0/4efd888895bdb3b987086b2b7793ad1013566f882b0eb7a328384e5ecc0d71cafb16bbeab3196200cbf7f01a73ccc25acc2f131d4ea6ee959be7436a8a306482 - languageName: node - linkType: hard - "tsort@npm:0.0.1": version: 0.0.1 resolution: "tsort@npm:0.0.1"