diff --git a/contracts/libraries/Transient.sol b/contracts/libraries/Transient.sol index 2fc37e7e..36083b00 100644 --- a/contracts/libraries/Transient.sol +++ b/contracts/libraries/Transient.sol @@ -72,6 +72,11 @@ library TransientLib { */ error MathUnderflow(); + // bytes32 private constant offset = keccak256(abi.encode(uint256(keccak256("TransientTest.storage.Offset")) - 1)) & ~bytes32(uint256(0xff)); + // @dev: this is the offset for the transient storage slot + // @dev: it is required because tload uses storage slot index and it may be a collision with transient storage slots + bytes32 private constant OFFSET = 0xb2e1616e94c4f038b21d9137633825dc3f28ecaa196ae6785bc038208b529200; + // ===================== Functions for tuint256 ===================== /** @@ -81,7 +86,7 @@ library TransientLib { */ function tload(tuint256 storage self) internal view returns (uint256 ret) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - ret := tload(self.slot) + ret := tload(add(self.slot, OFFSET)) } } @@ -92,7 +97,7 @@ library TransientLib { */ function tstore(tuint256 storage self, uint256 value) internal { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - tstore(self.slot, value) + tstore(add(self.slot, OFFSET), value) } } @@ -130,8 +135,8 @@ library TransientLib { */ function unsafeInc(tuint256 storage self) internal returns (uint256 incremented) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - incremented := add(tload(self.slot), 1) - tstore(self.slot, incremented) + incremented := add(tload(add(self.slot, OFFSET)), 1) + tstore(add(self.slot, OFFSET), incremented) } } @@ -169,8 +174,8 @@ library TransientLib { */ function unsafeDec(tuint256 storage self) internal returns (uint256 decremented) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - decremented := sub(tload(self.slot), 1) - tstore(self.slot, decremented) + decremented := sub(tload(add(self.slot, OFFSET)), 1) + tstore(add(self.slot, OFFSET), decremented) } } @@ -199,7 +204,7 @@ library TransientLib { */ function tload(taddress storage self) internal view returns (address ret) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - ret := tload(self.slot) + ret := tload(add(self.slot, OFFSET)) } } @@ -210,7 +215,7 @@ library TransientLib { */ function tstore(taddress storage self, address value) internal { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - tstore(self.slot, value) + tstore(add(self.slot, OFFSET), value) } } @@ -223,7 +228,7 @@ library TransientLib { */ function tload(tbytes32 storage self) internal view returns (bytes32 ret) { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - ret := tload(self.slot) + ret := tload(add(self.slot, OFFSET)) } } @@ -234,7 +239,7 @@ library TransientLib { */ function tstore(tbytes32 storage self, bytes32 value) internal { assembly ("memory-safe") { // solhint-disable-line no-inline-assembly - tstore(self.slot, value) + tstore(add(self.slot, OFFSET), value) } } } diff --git a/contracts/libraries/TransientLockUnsafe.sol b/contracts/libraries/TransientLockUnsafe.sol new file mode 100644 index 00000000..8c30ee70 --- /dev/null +++ b/contracts/libraries/TransientLockUnsafe.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import { TransientUnsafe, tuint256 } from "./TransientUnsafe.sol"; +import { TransientLock } from "./TransientLock.sol"; + +/** + * @title TransientLockUnsafeLib + * @dev Library for managing transient storage locks without slot offset. + * Provides gas-efficient reentrancy protection using raw storage slots. + * Safe for use with mappings where slots are already keccak256-hashed. + * WARNING: Do not use for simple struct fields — use TransientLockLib instead. + */ +library TransientLockUnsafeLib { + using TransientUnsafe for tuint256; + + /** + * @dev Error thrown when attempting to lock an already locked state. + */ + error UnexpectedLock(); + + /** + * @dev Error thrown when attempting to unlock a non-locked state. + */ + error UnexpectedUnlock(); + + uint256 constant private _UNLOCKED = 0; + uint256 constant private _LOCKED = 1; + + /** + * @dev Acquires the lock. Reverts with UnexpectedLock if already locked. + * @param self The transient lock to acquire. + */ + function lock(TransientLock storage self) internal { + require(self._raw.inc() == _LOCKED, UnexpectedLock()); + } + + /** + * @dev Releases the lock. Reverts with UnexpectedUnlock if not currently locked. + * @param self The transient lock to release. + */ + function unlock(TransientLock storage self) internal { + self._raw.dec(UnexpectedUnlock.selector); + } + + /** + * @dev Checks if the lock is currently held. + * @param self The transient lock to check. + * @return True if the lock is held, false otherwise. + */ + function isLocked(TransientLock storage self) internal view returns (bool) { + return self._raw.tload() == _LOCKED; + } +} diff --git a/contracts/libraries/TransientUnsafe.sol b/contracts/libraries/TransientUnsafe.sol new file mode 100644 index 00000000..8bc7ac1c --- /dev/null +++ b/contracts/libraries/TransientUnsafe.sol @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import { tuint256, taddress, tbytes32 } from "./Transient.sol"; + +/** + * @title TransientUnsafe + * @dev Library for transient storage without slot offset. + * Uses raw storage slots directly, saving 6 gas per access on dynamic slots (mappings). + * Safe for use with mappings where slots are already keccak256-hashed and cannot collide + * storage slots can collide with native `transient` keyword variables because + * transient storage has it is own space and storage slots for transient storage also starts from zero, + * so when we use transient lib which operates storage slots indexes from storage + * and collides if use transient lib for a varible with static storage index + * with Solidity's `transient` keyword (which does not support mappings). + * WARNING: Do not use for simple struct fields — use TransientLib instead to avoid + * potential slot collisions with native `transient` keyword variables. + */ +library TransientUnsafe { + /** + * @dev Error thrown when increment would cause overflow. + */ + error MathOverflow(); + + /** + * @dev Error thrown when decrement would cause underflow. + */ + error MathUnderflow(); + + // ===================== Functions for tuint256 ===================== + + /** + * @dev Loads a uint256 value from transient storage. + * @param self The transient uint256 storage slot. + * @return ret The value stored in transient storage. + */ + function tload(tuint256 storage self) internal view returns (uint256 ret) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + ret := tload(self.slot) + } + } + + /** + * @dev Stores a uint256 value to transient storage. + * @param self The transient uint256 storage slot. + * @param value The value to store. + */ + function tstore(tuint256 storage self, uint256 value) internal { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + tstore(self.slot, value) + } + } + + /** + * @dev Increments the transient uint256 value by 1 with overflow check. + * Reverts with MathOverflow if the value would overflow. + * @param self The transient uint256 storage slot. + * @return incremented The new value after incrementing. + */ + function inc(tuint256 storage self) internal returns (uint256 incremented) { + return inc(self, TransientUnsafe.MathOverflow.selector); + } + + /** + * @dev Increments the transient uint256 value by 1 with custom overflow error. + * @param self The transient uint256 storage slot. + * @param exception The error selector to revert with on overflow. + * @return incremented The new value after incrementing. + */ + function inc(tuint256 storage self, bytes4 exception) internal returns (uint256 incremented) { + incremented = unsafeInc(self); + if (incremented == 0) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + mstore(0, exception) + revert(0, 4) + } + } + } + + /** + * @dev Increments the transient uint256 value by 1 without overflow check. + * Warning: May overflow silently. + * @param self The transient uint256 storage slot. + * @return incremented The new value after incrementing. + */ + function unsafeInc(tuint256 storage self) internal returns (uint256 incremented) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + incremented := add(tload(self.slot), 1) + tstore(self.slot, incremented) + } + } + + /** + * @dev Decrements the transient uint256 value by 1 with underflow check. + * Reverts with MathUnderflow if the value would underflow. + * @param self The transient uint256 storage slot. + * @return decremented The new value after decrementing. + */ + function dec(tuint256 storage self) internal returns (uint256 decremented) { + return dec(self, TransientUnsafe.MathUnderflow.selector); + } + + /** + * @dev Decrements the transient uint256 value by 1 with custom underflow error. + * @param self The transient uint256 storage slot. + * @param exception The error selector to revert with on underflow. + * @return decremented The new value after decrementing. + */ + function dec(tuint256 storage self, bytes4 exception) internal returns (uint256 decremented) { + decremented = unsafeDec(self); + if (decremented == type(uint256).max) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + mstore(0, exception) + revert(0, 4) + } + } + } + + /** + * @dev Decrements the transient uint256 value by 1 without underflow check. + * Warning: May underflow silently. + * @param self The transient uint256 storage slot. + * @return decremented The new value after decrementing. + */ + function unsafeDec(tuint256 storage self) internal returns (uint256 decremented) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + decremented := sub(tload(self.slot), 1) + tstore(self.slot, decremented) + } + } + + /** + * @dev Initializes with a value if zero, then adds to the transient uint256. + * @param self The transient uint256 storage slot. + * @param initialValue The value to use if current value is zero. + * @param toAdd The value to add. + * @return result The final value after initialization and addition. + */ + function initAndAdd(tuint256 storage self, uint256 initialValue, uint256 toAdd) internal returns (uint256 result) { + result = tload(self); + if (result == 0) { + result = initialValue; + } + result += toAdd; + tstore(self, result); + } + + // ===================== Functions for taddress ===================== + + /** + * @dev Loads an address value from transient storage. + * @param self The transient address storage slot. + * @return ret The address stored in transient storage. + */ + function tload(taddress storage self) internal view returns (address ret) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + ret := tload(self.slot) + } + } + + /** + * @dev Stores an address value to transient storage. + * @param self The transient address storage slot. + * @param value The address to store. + */ + function tstore(taddress storage self, address value) internal { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + tstore(self.slot, value) + } + } + + // ===================== Functions for tbytes32 ===================== + + /** + * @dev Loads a bytes32 value from transient storage. + * @param self The transient bytes32 storage slot. + * @return ret The bytes32 value stored in transient storage. + */ + function tload(tbytes32 storage self) internal view returns (bytes32 ret) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + ret := tload(self.slot) + } + } + + /** + * @dev Stores a bytes32 value to transient storage. + * @param self The transient bytes32 storage slot. + * @param value The bytes32 value to store. + */ + function tstore(tbytes32 storage self, bytes32 value) internal { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + tstore(self.slot, value) + } + } +} diff --git a/contracts/tests/mocks/TransientLockNestedMock.sol b/contracts/tests/mocks/TransientLockNestedMock.sol new file mode 100644 index 00000000..6748351c --- /dev/null +++ b/contracts/tests/mocks/TransientLockNestedMock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "../../libraries/TransientLock.sol"; + +contract TransientLockNestedMock { + using TransientLockLib for TransientLock; + + mapping(address maker => mapping(bytes32 strategyHash => TransientLock)) internal _reentrancyLocks; + + function lock(address maker, bytes32 strategyHash) external { + _reentrancyLocks[maker][strategyHash].lock(); + } + + function unlock(address maker, bytes32 strategyHash) external { + _reentrancyLocks[maker][strategyHash].unlock(); + } + + function isLocked(address maker, bytes32 strategyHash) external view returns (bool) { + return _reentrancyLocks[maker][strategyHash].isLocked(); + } + +} diff --git a/contracts/tests/mocks/TransientLockUnsafeMock.sol b/contracts/tests/mocks/TransientLockUnsafeMock.sol new file mode 100644 index 00000000..9d83ad2f --- /dev/null +++ b/contracts/tests/mocks/TransientLockUnsafeMock.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "../../libraries/TransientLockUnsafe.sol"; + +contract TransientLockUnsafeMock { + using TransientLockUnsafeLib for TransientLock; + + TransientLock private _lock; + + function lock() external { + _lock.lock(); + } + + function unlock() external { + _lock.unlock(); + } + + function isLocked() external view returns (bool) { + return _lock.isLocked(); + } + + function lockAndCheck() external returns (bool) { + _lock.lock(); + return _lock.isLocked(); + } + + function lockUnlockAndCheck() external returns (bool) { + _lock.lock(); + _lock.unlock(); + return _lock.isLocked(); + } + + function doubleLock() external { + _lock.lock(); + _lock.lock(); + } + + function unlockWithoutLock() external { + _lock.unlock(); + } +} diff --git a/contracts/tests/mocks/TransientLockUnsafeNestedMock.sol b/contracts/tests/mocks/TransientLockUnsafeNestedMock.sol new file mode 100644 index 00000000..ca0ff871 --- /dev/null +++ b/contracts/tests/mocks/TransientLockUnsafeNestedMock.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "../../libraries/TransientLockUnsafe.sol"; + +contract TransientLockUnsafeNestedMock { + using TransientLockUnsafeLib for TransientLock; + + mapping(address maker => mapping(bytes32 strategyHash => TransientLock)) internal _reentrancyLocks; + + function lock(address maker, bytes32 strategyHash) external { + _reentrancyLocks[maker][strategyHash].lock(); + } + + function unlock(address maker, bytes32 strategyHash) external { + _reentrancyLocks[maker][strategyHash].unlock(); + } + + function isLocked(address maker, bytes32 strategyHash) external view returns (bool) { + return _reentrancyLocks[maker][strategyHash].isLocked(); + } + +} diff --git a/contracts/tests/mocks/TransientMock.sol b/contracts/tests/mocks/TransientMock.sol index d017dc50..946465f3 100644 --- a/contracts/tests/mocks/TransientMock.sol +++ b/contracts/tests/mocks/TransientMock.sol @@ -9,6 +9,7 @@ contract TransientMock { using TransientLib for tbytes32; struct Storage { + uint256 _padding; tuint256 uintValue; taddress addressValue; tbytes32 bytes32Value; @@ -81,4 +82,15 @@ contract TransientMock { function tstoreBytes32(bytes32 value) external { _storage.bytes32Value.tstore(value); } + + // offset verification + function computedOffset() external pure returns (bytes32) { + return keccak256(abi.encode(uint256(keccak256("TransientTest.storage.Offset")) - 1)) & ~bytes32(uint256(0xff)); + } + + function storedOffset() external pure returns (bytes32 ret) { + assembly ("memory-safe") { // solhint-disable-line no-inline-assembly + ret := 0xb2e1616e94c4f038b21d9137633825dc3f28ecaa196ae6785bc038208b529200 + } + } } diff --git a/contracts/tests/mocks/TransientUnsafeMock.sol b/contracts/tests/mocks/TransientUnsafeMock.sol new file mode 100644 index 00000000..c311ac0f --- /dev/null +++ b/contracts/tests/mocks/TransientUnsafeMock.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.30; + +import "../../libraries/TransientUnsafe.sol"; + +contract TransientUnsafeMock { + using TransientUnsafe for tuint256; + using TransientUnsafe for taddress; + using TransientUnsafe for tbytes32; + + struct Storage { + uint256 _padding; + tuint256 uintValue; + taddress addressValue; + tbytes32 bytes32Value; + } + + Storage private _storage; + + // tuint256 functions + function tloadUint() external view returns (uint256) { + return _storage.uintValue.tload(); + } + + function tstoreUint(uint256 value) external { + _storage.uintValue.tstore(value); + } + + function inc() external returns (uint256) { + return _storage.uintValue.inc(); + } + + function incWithException(bytes4 exception) external returns (uint256) { + return _storage.uintValue.inc(exception); + } + + function unsafeInc() external returns (uint256) { + return _storage.uintValue.unsafeInc(); + } + + function dec() external returns (uint256) { + return _storage.uintValue.dec(); + } + + function decWithException(bytes4 exception) external returns (uint256) { + return _storage.uintValue.dec(exception); + } + + function unsafeDec() external returns (uint256) { + return _storage.uintValue.unsafeDec(); + } + + function initAndAdd(uint256 initialValue, uint256 toAdd) external returns (uint256) { + return _storage.uintValue.initAndAdd(initialValue, toAdd); + } + + function incFromMaxValue() external returns (uint256) { + _storage.uintValue.tstore(type(uint256).max); + return _storage.uintValue.inc(); + } + + function incFromMaxValueWithException(bytes4 exception) external returns (uint256) { + _storage.uintValue.tstore(type(uint256).max); + return _storage.uintValue.inc(exception); + } + + // taddress functions + function tloadAddress() external view returns (address) { + return _storage.addressValue.tload(); + } + + function tstoreAddress(address value) external { + _storage.addressValue.tstore(value); + } + + // tbytes32 functions + function tloadBytes32() external view returns (bytes32) { + return _storage.bytes32Value.tload(); + } + + function tstoreBytes32(bytes32 value) external { + _storage.bytes32Value.tstore(value); + } + + function computedOffset() external pure returns (bytes32) { + return keccak256(abi.encode(uint256(keccak256("TransientTest.storage.Offset")) - 1)) & ~bytes32(uint256(0xff)); + } + + function storedOffset() external pure returns (bytes32) { + return bytes32(0); + } +} diff --git a/package.json b/package.json index 45b67577..cb70de95 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@1inch/solidity-utils", - "version": "6.9.5", + "version": "6.9.6", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", "exports": { diff --git a/src/profileEVM.ts b/src/profileEVM.ts index 250eba21..5b0c223d 100644 --- a/src/profileEVM.ts +++ b/src/profileEVM.ts @@ -1,5 +1,26 @@ import { PathLike, promises as fs } from 'fs'; -import { JsonRpcProvider } from 'ethers'; +import { ContractTransactionResponse, JsonRpcProvider } from 'ethers'; + +/** + * @category profileEVM + * Measures pure execution gas of a transaction using `debug_traceTransaction`. + * Unlike `receipt.gasUsed`, this excludes intrinsic gas overhead (21000 base + calldata costs), + * giving a cleaner comparison of contract execution costs. + * @param provider An Ethereum provider capable of sending custom RPC requests. + * @param txPromise A promise that resolves to a sent transaction. + * @return The execution gas consumed by the transaction's EVM operations. + */ +export async function executionGas( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider: JsonRpcProvider | { send: (method: string, params: unknown[]) => Promise }, + txPromise: Promise, +): Promise { + const tx = await txPromise; + await tx.wait(); + const trace = await provider.send('debug_traceTransaction', [tx.hash]); + const logs: { gas: number; gasCost: number }[] = trace.structLogs; + return logs[0].gas - logs[logs.length - 1].gas + logs[logs.length - 1].gasCost; +} /** * @category profileEVM diff --git a/test/contracts/Transient.test.ts b/test/contracts/Transient.test.ts index 16bddab5..515e17f9 100644 --- a/test/contracts/Transient.test.ts +++ b/test/contracts/Transient.test.ts @@ -1,121 +1,231 @@ import { expect } from '../../src/expect'; +import { executionGas } from '../../src/profileEVM'; import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; -import { ethers } from 'hardhat'; - -describe('Transient', function () { - async function deployTransientMock() { - const TransientMock = await ethers.getContractFactory('TransientMock'); - const mock = await TransientMock.deploy(); - return { mock }; - } - - describe('tuint256', function () { - describe('tload/tstore', function () { - it('should return 0 for uninitialized value', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.tloadUint()).to.equal(0n); +import hre, { ethers } from 'hardhat'; +import type { TransientMock } from '../../typechain-types/contracts/tests/mocks/TransientMock'; +import type { TransientUnsafeMock } from '../../typechain-types/contracts/tests/mocks/TransientUnsafeMock'; + +for (const contractName of ['TransientMock', 'TransientUnsafeMock']) { + describe(contractName, function () { + async function deployTransientMock(): Promise<{ mock: TransientMock | TransientUnsafeMock }> { + const TransientMock = await ethers.getContractFactory(contractName); + const mock = await TransientMock.deploy() as unknown as TransientMock | TransientUnsafeMock; + return { mock }; + } + + describe('tuint256', function () { + describe('tload/tstore', function () { + it('should return 0 for uninitialized value', async function () { + const { mock } = await loadFixture(deployTransientMock); + expect(await mock.tloadUint()).to.equal(0n); + }); + + it('should store and load value in same tx via staticCall', async function () { + const { mock } = await loadFixture(deployTransientMock); + // Transient storage is cleared between transactions + // We test store+load in single tx using initAndAdd which does both + expect(await mock.initAndAdd.staticCall(42, 0)).to.equal(42n); + }); + + it('should clear value between transactions', async function () { + const { mock } = await loadFixture(deployTransientMock); + await mock.tstoreUint(100); + // In next transaction, transient storage is cleared + expect(await mock.tloadUint()).to.equal(0n); + }); }); - it('should store and load value in same tx via staticCall', async function () { - const { mock } = await loadFixture(deployTransientMock); - // Transient storage is cleared between transactions - // We test store+load in single tx using initAndAdd which does both - expect(await mock.initAndAdd.staticCall(42, 0)).to.equal(42n); + describe('inc', function () { + it('should increment from 0 to 1', async function () { + const { mock } = await loadFixture(deployTransientMock); + expect(await mock.inc.staticCall()).to.equal(1n); + }); + + it('should increment sequentially in same transaction', async function () { + const { mock } = await loadFixture(deployTransientMock); + // Note: Each call is separate transaction, so always starts from 0 + expect(await mock.inc.staticCall()).to.equal(1n); + }); + + it('should revert on overflow (when incremented == 0)', async function () { + const { mock } = await loadFixture(deployTransientMock); + await expect(mock.incFromMaxValue()).to.be.reverted; + }); + + it('should revert with custom exception on overflow', async function () { + const { mock } = await loadFixture(deployTransientMock); + const customSelector = '0xdeadbeef'; + await expect(mock.incFromMaxValueWithException(customSelector)).to.be.reverted; + }); }); - it('should clear value between transactions', async function () { - const { mock } = await loadFixture(deployTransientMock); - await mock.tstoreUint(100); - // In next transaction, transient storage is cleared - expect(await mock.tloadUint()).to.equal(0n); + describe('dec', function () { + it('should revert on underflow from 0', async function () { + const { mock } = await loadFixture(deployTransientMock); + await expect(mock.dec()).to.be.reverted; + }); + + it('should revert with custom exception on underflow', async function () { + const { mock } = await loadFixture(deployTransientMock); + const customSelector = '0x12345678'; + await expect(mock.decWithException(customSelector)).to.be.reverted; + }); }); - }); - describe('inc', function () { - it('should increment from 0 to 1', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.inc.staticCall()).to.equal(1n); + describe('unsafeInc/unsafeDec', function () { + it('should increment without check', async function () { + const { mock } = await loadFixture(deployTransientMock); + expect(await mock.unsafeInc.staticCall()).to.equal(1n); + }); + + it('should underflow without reverting', async function () { + const { mock } = await loadFixture(deployTransientMock); + // unsafeDec from 0 should return max uint256 + expect(await mock.unsafeDec.staticCall()).to.equal(ethers.MaxUint256); + }); }); - it('should increment sequentially in same transaction', async function () { - const { mock } = await loadFixture(deployTransientMock); - // Note: Each call is separate transaction, so always starts from 0 - expect(await mock.inc.staticCall()).to.equal(1n); + describe('initAndAdd', function () { + it('should initialize with value if zero and add', async function () { + const { mock } = await loadFixture(deployTransientMock); + expect(await mock.initAndAdd.staticCall(100, 5)).to.equal(105n); + }); }); + }); - it('should revert on overflow (when incremented == 0)', async function () { + describe('taddress', function () { + it('should return zero address for uninitialized', async function () { const { mock } = await loadFixture(deployTransientMock); - await expect(mock.incFromMaxValue()).to.be.reverted; + expect(await mock.tloadAddress()).to.equal(ethers.ZeroAddress); }); - it('should revert with custom exception on overflow', async function () { + it('should clear address between transactions', async function () { const { mock } = await loadFixture(deployTransientMock); - const customSelector = '0xdeadbeef'; - await expect(mock.incFromMaxValueWithException(customSelector)).to.be.reverted; + const testAddress = '0x1234567890123456789012345678901234567890'; + await mock.tstoreAddress(testAddress); + // Transient storage cleared between txs + expect(await mock.tloadAddress()).to.equal(ethers.ZeroAddress); }); }); - describe('dec', function () { - it('should revert on underflow from 0', async function () { + describe('tbytes32', function () { + it('should return zero for uninitialized', async function () { const { mock } = await loadFixture(deployTransientMock); - await expect(mock.dec()).to.be.reverted; + expect(await mock.tloadBytes32()).to.equal(ethers.ZeroHash); }); - it('should revert with custom exception on underflow', async function () { + it('should clear bytes32 between transactions', async function () { const { mock } = await loadFixture(deployTransientMock); - const customSelector = '0x12345678'; - await expect(mock.decWithException(customSelector)).to.be.reverted; + const testBytes = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + await mock.tstoreBytes32(testBytes); + // Transient storage cleared between txs + expect(await mock.tloadBytes32()).to.equal(ethers.ZeroHash); }); }); + }); +} - describe('unsafeInc/unsafeDec', function () { - it('should increment without check', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.unsafeInc.staticCall()).to.equal(1n); - }); +describe('Offset', function () { + async function deployTransientMock() { + const TransientMock = await ethers.getContractFactory('TransientMock'); + const mock = await TransientMock.deploy(); + return { mock }; + } - it('should underflow without reverting', async function () { - const { mock } = await loadFixture(deployTransientMock); - // unsafeDec from 0 should return max uint256 - expect(await mock.unsafeDec.staticCall()).to.equal(ethers.MaxUint256); - }); - }); + it('should have stored OFFSET equal to the computed formula', async function () { + const { mock } = await loadFixture(deployTransientMock); + const computed = await mock.computedOffset(); + const stored = await mock.storedOffset(); + expect(stored).to.equal(computed); + }); - describe('initAndAdd', function () { - it('should initialize with value if zero and add', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.initAndAdd.staticCall(100, 5)).to.equal(105n); - }); - }); + it('should match the expected hardcoded value', async function () { + const { mock } = await loadFixture(deployTransientMock); + const stored = await mock.storedOffset(); + expect(stored).to.equal('0xb2e1616e94c4f038b21d9137633825dc3f28ecaa196ae6785bc038208b529200'); }); - describe('taddress', function () { - it('should return zero address for uninitialized', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.tloadAddress()).to.equal(ethers.ZeroAddress); - }); + it('should match ethers-computed value', async function () { + const { mock } = await loadFixture(deployTransientMock); + const innerHash = ethers.keccak256(ethers.toUtf8Bytes('TransientTest.storage.Offset')); + const subtracted = BigInt(innerHash) - 1n; + const encoded = ethers.AbiCoder.defaultAbiCoder().encode(['uint256'], [subtracted]); + const outerHash = ethers.keccak256(encoded); + const mask = ~BigInt('0xff'); + const expected = BigInt(outerHash) & mask; + const expectedHex = '0x' + expected.toString(16).padStart(64, '0'); + expect(await mock.storedOffset()).to.equal(expectedHex); + }); +}); - it('should clear address between transactions', async function () { - const { mock } = await loadFixture(deployTransientMock); - const testAddress = '0x1234567890123456789012345678901234567890'; - await mock.tstoreAddress(testAddress); - // Transient storage cleared between txs - expect(await mock.tloadAddress()).to.equal(ethers.ZeroAddress); - }); +describe('Gas comparison: TransientLib (with offset) vs TransientUnsafe (without offset)', function () { + async function deployBothMocks() { + const TransientMock = await ethers.getContractFactory('TransientMock'); + const safe = await TransientMock.deploy(); + const TransientUnsafeMock = await ethers.getContractFactory('TransientUnsafeMock'); + const unsafe = await TransientUnsafeMock.deploy(); + return { safe, unsafe }; + } + + before(function () { + if (hre.__SOLIDITY_COVERAGE_RUNNING) { this.skip(); } }); - describe('tbytes32', function () { - it('should return zero for uninitialized', async function () { - const { mock } = await loadFixture(deployTransientMock); - expect(await mock.tloadBytes32()).to.equal(ethers.ZeroHash); - }); + it('tstore uint256', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const safeGas = await executionGas(ethers.provider, safe.tstoreUint(42)); + const unsafeGas = await executionGas(ethers.provider, unsafe.tstoreUint(42)); + console.log(` tstore uint256 — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); - it('should clear bytes32 between transactions', async function () { - const { mock } = await loadFixture(deployTransientMock); - const testBytes = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; - await mock.tstoreBytes32(testBytes); - // Transient storage cleared between txs - expect(await mock.tloadBytes32()).to.equal(ethers.ZeroHash); - }); + it('inc', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const safeGas = await executionGas(ethers.provider, safe.inc()); + const unsafeGas = await executionGas(ethers.provider, unsafe.inc()); + console.log(` inc — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + + it('unsafeInc', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const safeGas = await executionGas(ethers.provider, safe.unsafeInc()); + const unsafeGas = await executionGas(ethers.provider, unsafe.unsafeInc()); + console.log(` unsafeInc — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + + it('unsafeDec', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const safeGas = await executionGas(ethers.provider, safe.unsafeDec()); + const unsafeGas = await executionGas(ethers.provider, unsafe.unsafeDec()); + console.log(` unsafeDec — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + + it('initAndAdd', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const safeGas = await executionGas(ethers.provider, safe.initAndAdd(100, 5)); + const unsafeGas = await executionGas(ethers.provider, unsafe.initAndAdd(100, 5)); + console.log(` initAndAdd — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + + it('tstore address', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const addr = '0x1234567890123456789012345678901234567890'; + const safeGas = await executionGas(ethers.provider, safe.tstoreAddress(addr)); + const unsafeGas = await executionGas(ethers.provider, unsafe.tstoreAddress(addr)); + console.log(` tstore address — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + + it('tstore bytes32', async function () { + const { safe, unsafe } = await loadFixture(deployBothMocks); + const val = '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'; + const safeGas = await executionGas(ethers.provider, safe.tstoreBytes32(val)); + const unsafeGas = await executionGas(ethers.provider, unsafe.tstoreBytes32(val)); + console.log(` tstore bytes32 — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); }); }); diff --git a/test/contracts/TransientLock.test.ts b/test/contracts/TransientLock.test.ts index 72e966b7..da4dbb0b 100644 --- a/test/contracts/TransientLock.test.ts +++ b/test/contracts/TransientLock.test.ts @@ -2,58 +2,58 @@ import { expect } from '../../src/expect'; import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; import { ethers } from 'hardhat'; -describe('TransientLock', function () { - async function deployTransientLockMock() { - const TransientLockMock = await ethers.getContractFactory('TransientLockMock'); - const mock = await TransientLockMock.deploy(); - return { mock }; - } - - describe('lock', function () { - it('should lock successfully when unlocked', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - await expect(mock.lock()).not.to.be.reverted; +for (const contractName of ['TransientLockMock', 'TransientLockUnsafeMock']) { + describe(contractName, function () { + async function deployMock() { + const mock = await (await ethers.getContractFactory(contractName)).deploy(); + return { mock }; + } + + describe('lock', function () { + it('should lock successfully when unlocked', async function () { + const { mock } = await loadFixture(deployMock); + await expect(mock.lock()).not.to.be.reverted; + }); + + it('should be locked after lock() call within same transaction', async function () { + const { mock } = await loadFixture(deployMock); + expect(await mock.lockAndCheck.staticCall()).to.equal(true); + }); }); - it('should be locked after lock() call within same transaction', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - expect(await mock.lockAndCheck.staticCall()).to.equal(true); + describe('unlock', function () { + it('should revert when unlocking without lock', async function () { + const { mock } = await loadFixture(deployMock); + await expect(mock.unlockWithoutLock()).to.be.reverted; + }); }); - }); - describe('unlock', function () { - it('should revert when unlocking without lock', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - await expect(mock.unlockWithoutLock()).to.be.reverted; - }); - }); + describe('isLocked', function () { + it('should return false initially', async function () { + const { mock } = await loadFixture(deployMock); + expect(await mock.isLocked()).to.equal(false); + }); - describe('isLocked', function () { - it('should return false initially', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - expect(await mock.isLocked()).to.equal(false); + it('should return false after lock and unlock in same transaction', async function () { + const { mock } = await loadFixture(deployMock); + expect(await mock.lockUnlockAndCheck.staticCall()).to.equal(false); + }); }); - it('should return false after lock and unlock in same transaction', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - expect(await mock.lockUnlockAndCheck.staticCall()).to.equal(false); + describe('double lock', function () { + it('should revert on double lock', async function () { + const { mock } = await loadFixture(deployMock); + await expect(mock.doubleLock()) + .to.be.revertedWithCustomError(mock, 'UnexpectedLock'); + }); }); - }); - - describe('double lock', function () { - it('should revert on double lock', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - await expect(mock.doubleLock()) - .to.be.revertedWithCustomError(mock, 'UnexpectedLock'); - }); - }); - describe('transient behavior', function () { - it('should reset lock state between transactions', async function () { - const { mock } = await loadFixture(deployTransientLockMock); - await mock.lock(); - // In next transaction, lock should be released - expect(await mock.isLocked()).to.equal(false); + describe('transient behavior', function () { + it('should reset lock state between transactions', async function () { + const { mock } = await loadFixture(deployMock); + await mock.lock(); + expect(await mock.isLocked()).to.equal(false); + }); }); }); -}); +} diff --git a/test/contracts/TransientLockNested.test.ts b/test/contracts/TransientLockNested.test.ts new file mode 100644 index 00000000..18d4fa71 --- /dev/null +++ b/test/contracts/TransientLockNested.test.ts @@ -0,0 +1,63 @@ +import { expect } from '../../src/expect'; +import { executionGas } from '../../src/profileEVM'; +import { loadFixture } from '@nomicfoundation/hardhat-network-helpers'; +import hre, { ethers } from 'hardhat'; + +const MAKER = '0x1111111111111111111111111111111111111111'; +const STRATEGY = ethers.id('strategy-1'); + +describe('TransientLock nested mapping', function () { + async function deployNestedMocks() { + const safe = await (await ethers.getContractFactory('TransientLockNestedMock')).deploy(); + const unsafe = await (await ethers.getContractFactory('TransientLockUnsafeNestedMock')).deploy(); + return { safe, unsafe }; + } + + describe('TransientLockLib (with offset)', function () { + it('should lock successfully', async function () { + const { safe } = await loadFixture(deployNestedMocks); + await expect(safe.lock(MAKER, STRATEGY)).not.to.be.reverted; + }); + + it('should return false initially', async function () { + const { safe } = await loadFixture(deployNestedMocks); + expect(await safe.isLocked(MAKER, STRATEGY)).to.equal(false); + }); + + it('should reset between transactions', async function () { + const { safe } = await loadFixture(deployNestedMocks); + await safe.lock(MAKER, STRATEGY); + expect(await safe.isLocked(MAKER, STRATEGY)).to.equal(false); + }); + + it('should isolate different maker/strategy pairs', async function () { + const { safe } = await loadFixture(deployNestedMocks); + const otherMaker = '0x2222222222222222222222222222222222222222'; + const otherStrategy = ethers.id('strategy-2'); + await safe.lock(MAKER, STRATEGY); + expect(await safe.isLocked(otherMaker, STRATEGY)).to.equal(false); + expect(await safe.isLocked(MAKER, otherStrategy)).to.equal(false); + }); + }); + + describe('TransientLockUnsafeLib (without offset)', function () { + it('should lock successfully', async function () { + const { unsafe } = await loadFixture(deployNestedMocks); + await expect(unsafe.lock(MAKER, STRATEGY)).not.to.be.reverted; + }); + }); + + describe('Gas comparison: TransientLockLib vs TransientLockUnsafeLib (nested mapping)', function () { + before(function () { + if (hre.__SOLIDITY_COVERAGE_RUNNING) { this.skip(); } + }); + + it('lock', async function () { + const { safe, unsafe } = await loadFixture(deployNestedMocks); + const safeGas = await executionGas(ethers.provider, safe.lock(MAKER, STRATEGY)); + const unsafeGas = await executionGas(ethers.provider, unsafe.lock(MAKER, STRATEGY)); + console.log(` lock — safe: ${safeGas}, unsafe: ${unsafeGas}, delta: ${safeGas - unsafeGas}`); + expect(safeGas).to.be.gte(unsafeGas); + }); + }); +});