diff --git a/contracts/0.8.9/WithdrawalQueue.sol b/contracts/0.8.9/WithdrawalQueue.sol index 23caa00f6..77a2b23f4 100644 --- a/contracts/0.8.9/WithdrawalQueue.sol +++ b/contracts/0.8.9/WithdrawalQueue.sol @@ -85,9 +85,7 @@ abstract contract WithdrawalQueue is AccessControlEnumerable, PausableUntil, Wit /// @dev Reverts if `_admin` equals to `address(0)` /// @dev NB! It's initialized in paused state by default and should be resumed explicitly to start /// @dev NB! Bunker mode is disabled by default - function initialize(address _admin) - external - { + function initialize(address _admin) external { if (_admin == address(0)) revert AdminZeroAddress(); _initialize(_admin); @@ -302,25 +300,15 @@ abstract contract WithdrawalQueue is AccessControlEnumerable, PausableUntil, Wit } } - /// @notice Finalize requests from last finalized one up to `_lastRequestIdToFinalize` - /// @dev ether to finalize all the requests should be calculated using `finalizationValue()` and sent along - function finalize(uint256[] calldata _batches, uint256 _maxShareRate) - external - payable - { - _checkResumed(); - _checkRole(FINALIZE_ROLE, msg.sender); - - _finalize(_batches, msg.value, _maxShareRate); - } - /// @notice Update bunker mode state and last report timestamp /// @dev should be called by oracle /// /// @param _isBunkerModeNow is bunker mode reported by oracle /// @param _bunkerStartTimestamp timestamp of start of the bunker mode /// @param _currentReportTimestamp timestamp of the current report ref slot - function onOracleReport(bool _isBunkerModeNow, uint256 _bunkerStartTimestamp, uint256 _currentReportTimestamp) external { + function onOracleReport(bool _isBunkerModeNow, uint256 _bunkerStartTimestamp, uint256 _currentReportTimestamp) + external + { _checkRole(ORACLE_ROLE, msg.sender); if (_bunkerStartTimestamp >= block.timestamp) revert InvalidReportTimestamp(); if (_currentReportTimestamp >= block.timestamp) revert InvalidReportTimestamp(); @@ -359,9 +347,7 @@ abstract contract WithdrawalQueue is AccessControlEnumerable, PausableUntil, Wit function _emitTransfer(address from, address to, uint256 _requestId) internal virtual; /// @dev internal initialization helper. Doesn't check provided addresses intentionally - function _initialize(address _admin) - internal - { + function _initialize(address _admin) internal { _initializeQueue(); _pauseFor(PAUSE_INFINITELY); @@ -405,7 +391,7 @@ abstract contract WithdrawalQueue is AccessControlEnumerable, PausableUntil, Wit } } - /// @notice returns claimable ether under the request with _requestId. + /// @notice returns claimable ether under the request with _requestId. Returns 0 if request is not finalized or already claimed function _getClaimableEther(uint256 _requestId, uint256 _hint) internal view returns (uint256) { if (_requestId == 0 || _requestId > getLastRequestId()) revert InvalidRequestId(_requestId); diff --git a/contracts/0.8.9/WithdrawalQueueBase.sol b/contracts/0.8.9/WithdrawalQueueBase.sol index 27a753d7d..ca57c642d 100644 --- a/contracts/0.8.9/WithdrawalQueueBase.sol +++ b/contracts/0.8.9/WithdrawalQueueBase.sol @@ -41,7 +41,6 @@ abstract contract WithdrawalQueueBase { /// @dev timestamp of the last oracle report bytes32 internal constant LAST_REPORT_TIMESTAMP_POSITION = keccak256("lido.WithdrawalQueue.lastReportTimestamp"); - /// @notice structure representing a request for withdrawal. struct WithdrawalRequest { /// @notice sum of the all stETH submitted for withdrawals up to this request @@ -217,11 +216,7 @@ abstract contract WithdrawalQueueBase { uint256 _maxTimestamp, uint256 _maxRequestsPerCall, BatchesCalculationState memory _state - ) - external - view - returns (BatchesCalculationState memory) - { + ) external view returns (BatchesCalculationState memory) { if (_state.finished || _state.remainingEthBudget == 0) revert InvalidState(); uint256 currentId; @@ -246,7 +241,7 @@ abstract contract WithdrawalQueueBase { while (currentId < queueLength && currentId < nextCallRequestId) { WithdrawalRequest memory request = _getQueue()[currentId]; - if (request.timestamp > _maxTimestamp) break; // max timestamp break + if (request.timestamp > _maxTimestamp) break; // max timestamp break (uint256 requestShareRate, uint256 ethToFinalize, uint256 shares) = _calcBatch(prevRequest, request); @@ -364,7 +359,7 @@ abstract contract WithdrawalQueueBase { _amountOfETH, requestToFinalize.cumulativeShares - lastFinalizedRequest.cumulativeShares, block.timestamp - ); + ); } /// @dev creates a new `WithdrawalRequest` in the queue @@ -494,7 +489,7 @@ abstract contract WithdrawalQueueBase { } /// @dev Calculates discounted ether value for `_requestId` using a provided `_hint`. Checks if hint is valid - /// @return claimableEther discounted eth for `_requestId`. Returns 0 if request is not claimable + /// @return claimableEther discounted eth for `_requestId` function _calculateClaimableEther(WithdrawalRequest storage _request, uint256 _requestId, uint256 _hint) internal view @@ -545,10 +540,11 @@ abstract contract WithdrawalQueueBase { } /// @dev calculate batch stats (shareRate, stETH and shares) for the batch of `(_preStartRequest, _endRequest]` - function _calcBatch( - WithdrawalRequest memory _preStartRequest, - WithdrawalRequest memory _endRequest - ) internal pure returns (uint256 shareRate, uint256 stETH, uint256 shares) { + function _calcBatch(WithdrawalRequest memory _preStartRequest, WithdrawalRequest memory _endRequest) + internal + pure + returns (uint256 shareRate, uint256 stETH, uint256 shares) + { stETH = _endRequest.cumulativeStETH - _preStartRequest.cumulativeStETH; shares = _endRequest.cumulativeShares - _preStartRequest.cumulativeShares; diff --git a/contracts/0.8.9/WithdrawalQueueERC721.sol b/contracts/0.8.9/WithdrawalQueueERC721.sol index 67a2f1c08..a43ddd18c 100644 --- a/contracts/0.8.9/WithdrawalQueueERC721.sol +++ b/contracts/0.8.9/WithdrawalQueueERC721.sol @@ -8,6 +8,7 @@ import {IERC721} from "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol"; import {IERC721Receiver} from "@openzeppelin/contracts-v4.4/token/ERC721/IERC721Receiver.sol"; import {IERC721Metadata} from "@openzeppelin/contracts-v4.4/token/ERC721/extensions/IERC721Metadata.sol"; import {IERC165} from "@openzeppelin/contracts-v4.4/utils/introspection/IERC165.sol"; +import {IERC4906} from "./interfaces/IERC4906.sol"; import {EnumerableSet} from "@openzeppelin/contracts-v4.4/utils/structs/EnumerableSet.sol"; import {Address} from "@openzeppelin/contracts-v4.4/utils/Address.sol"; @@ -18,14 +19,10 @@ import {AccessControlEnumerable} from "./utils/access/AccessControlEnumerable.so import {UnstructuredRefStorage} from "./lib/UnstructuredRefStorage.sol"; import {UnstructuredStorage} from "./lib/UnstructuredStorage.sol"; -/** - * @title Interface defining INFTDescriptor to generate ERC721 tokenURI - */ +/// @title Interface defining INFTDescriptor to generate ERC721 tokenURI interface INFTDescriptor { - /** - * @notice Returns ERC721 tokenURI content - * @param _requestId is an id for particular withdrawal request - */ + /// @notice Returns ERC721 tokenURI content + /// @param _requestId is an id for particular withdrawal request function constructTokenURI(uint256 _requestId) external view returns (string memory); } @@ -33,7 +30,7 @@ interface INFTDescriptor { /// NFT is minted on every request and burned on claim /// /// @author psirex, folkyatina -contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { +contract WithdrawalQueueERC721 is IERC721Metadata, IERC4906, WithdrawalQueue { using Address for address; using Strings for uint256; using EnumerableSet for EnumerableSet.UintSet; @@ -43,7 +40,8 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { bytes32 internal constant TOKEN_APPROVALS_POSITION = keccak256("lido.WithdrawalQueueERC721.tokenApprovals"); bytes32 internal constant OPERATOR_APPROVALS_POSITION = keccak256("lido.WithdrawalQueueERC721.operatorApprovals"); bytes32 internal constant BASE_URI_POSITION = keccak256("lido.WithdrawalQueueERC721.baseUri"); - bytes32 internal constant NFT_DESCRIPTOR_ADDRESS_POSITION = keccak256("lido.WithdrawalQueueERC721.nftDescriptorAddress"); + bytes32 internal constant NFT_DESCRIPTOR_ADDRESS_POSITION = + keccak256("lido.WithdrawalQueueERC721.nftDescriptorAddress"); bytes32 public constant MANAGE_TOKEN_URI_ROLE = keccak256("MANAGE_TOKEN_URI_ROLE"); @@ -91,7 +89,8 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { returns (bool) { return interfaceId == type(IERC721).interfaceId || interfaceId == type(IERC721Metadata).interfaceId - || super.supportsInterface(interfaceId); + // 0x49064906 is magic number ERC4906 interfaceId as defined in the standard https://eips.ethereum.org/EIPS/eip-4906 + || interfaceId == bytes4(0x49064906) || super.supportsInterface(interfaceId); } /// @dev Se_toBytes321Metadata-name}. @@ -114,8 +113,7 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { if (nftDescriptorAddress != address(0)) { return INFTDescriptor(nftDescriptorAddress).constructTokenURI(_requestId); } else { - string memory baseURI = _getBaseURI().value; - return bytes(baseURI).length > 0 ? string(abi.encodePacked(baseURI, _requestId.toString())) : ""; + return _constructTokenUri(_requestId); } } @@ -125,7 +123,7 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { return _getBaseURI().value; } - /// @notice Sets the Base URI for computing {tokenURI} + /// @notice Sets the Base URI for computing {tokenURI}. It does not expect the ending slash in provided string. /// @dev If NFTDescriptor address isn't set the `baseURI` would be used for generating erc721 tokenURI. In case /// NFTDescriptor address is set it would be used as a first-priority method. function setBaseURI(string calldata _baseURI) external onlyRole(MANAGE_TOKEN_URI_ROLE) { @@ -146,6 +144,18 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { emit NftDescriptorAddressSet(_nftDescriptorAddress); } + /// @notice Finalize requests from last finalized one up to `_lastRequestIdToFinalize` + /// @dev ether to finalize all the requests should be calculated using `finalizationValue()` and sent along + function finalize(uint256[] calldata _batches, uint256 _maxShareRate) external payable { + _checkResumed(); + _checkRole(FINALIZE_ROLE, msg.sender); + + _finalize(_batches, msg.value, _maxShareRate); + + // ERC4906 metadata update event + emit BatchMetadataUpdate(getLastFinalizedRequestId() + 1, _batches[_batches.length - 1]); + } + /// @dev See {IERC721-balanceOf}. function balanceOf(address _owner) external view override returns (uint256) { if (_owner == address(0)) revert InvalidOwnerAddress(_owner); @@ -227,7 +237,9 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { address msgSender = msg.sender; if ( !(_from == msgSender || isApprovedForAll(_from, msgSender) || _getTokenApprovals()[_requestId] == msgSender) - ) revert NotOwnerOrApproved(msgSender); + ) { + revert NotOwnerOrApproved(msgSender); + } delete _getTokenApprovals()[_requestId]; request.owner = _to; @@ -338,4 +350,42 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue { baseURI.slot := position } } + + function _constructTokenUri(uint256 _requestId) internal view returns (string memory) { + string memory baseURI = _getBaseURI().value; + if (bytes(baseURI).length == 0) return ""; + + // ${baseUri}/${_requestId}?requested=${amount}&created_at=${timestamp}[&finalized=${claimableAmount}] + string memory uri = string( + // we have no string.concat in 0.8.9 yet, so we have to do it with bytes.concat + bytes.concat( + bytes(baseURI), + bytes("/"), + bytes(_requestId.toString()), + bytes("?requested="), + bytes( + uint256(_getQueue()[_requestId].cumulativeStETH - _getQueue()[_requestId - 1].cumulativeStETH) + .toString() + ), + bytes("&created_at="), + bytes(uint256(_getQueue()[_requestId].timestamp).toString()) + ) + ); + bool finalized = _requestId <= getLastFinalizedRequestId(); + + if (finalized) { + uri = string( + bytes.concat( + bytes(uri), + bytes("&finalized="), + bytes( + _getClaimableEther(_requestId, _findCheckpointHint(_requestId, 1, getLastCheckpointIndex())) + .toString() + ) + ) + ); + } + + return uri; + } } diff --git a/contracts/0.8.9/interfaces/IERC4906.sol b/contracts/0.8.9/interfaces/IERC4906.sol new file mode 100644 index 000000000..506608ee8 --- /dev/null +++ b/contracts/0.8.9/interfaces/IERC4906.sol @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2023 OpenZeppelin, Lido +// SPDX-License-Identifier: MIT + +// Based on https://github.com/OpenZeppelin/openzeppelin-contracts/blob/96a2297e15f1a4bbcf470d2d0d6cb9c579c63893/contracts/interfaces/IERC4906.sol + +pragma solidity 0.8.9; + +import {IERC165} from "@openzeppelin/contracts-v4.4/utils/introspection/IERC165.sol"; +import {IERC721} from "@openzeppelin/contracts-v4.4/token/ERC721/IERC721.sol"; + +/// @title EIP-721 Metadata Update Extension +interface IERC4906 is IERC165, IERC721 { + /// @dev This event emits when the metadata of a token is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFT. + event MetadataUpdate(uint256 _tokenId); + + /// @dev This event emits when the metadata of a range of tokens is changed. + /// So that the third-party platforms such as NFT market could + /// timely update the images and related attributes of the NFTs. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); +} diff --git a/test/0.8.9/withdrawal-nft-gas.test.js b/test/0.8.9/withdrawal-nft-gas.test.js new file mode 100644 index 000000000..972e69869 --- /dev/null +++ b/test/0.8.9/withdrawal-nft-gas.test.js @@ -0,0 +1,64 @@ +const { contract, web3 } = require('hardhat') + +const { ETH, StETH, shareRate, shares } = require('../helpers/utils') +const { assert } = require('../helpers/assert') + +const { deployWithdrawalQueue } = require('./withdrawal-queue-deploy.test') + +contract('WithdrawalQueue', ([owner, daoAgent, user, tokenUriManager]) => { + let withdrawalQueue + + before('deploy', async function () { + if (!process.env.REPORT_GAS) { + this.skip() + } + const deployed = await deployWithdrawalQueue({ + stethOwner: owner, + queueAdmin: daoAgent, + queuePauser: daoAgent, + queueResumer: daoAgent, + queueFinalizer: daoAgent, + }) + + const steth = deployed.steth + withdrawalQueue = deployed.withdrawalQueue + await withdrawalQueue.grantRole(web3.utils.keccak256('MANAGE_TOKEN_URI_ROLE'), tokenUriManager, { from: daoAgent }) + await withdrawalQueue.setBaseURI('http://example.com', { from: tokenUriManager }) + + await steth.setTotalPooledEther(ETH(600)) + await steth.mintShares(user, shares(1)) + await steth.approve(withdrawalQueue.address, StETH(300), { from: user }) + }) + + it('findCheckpointHints gas spendings', async () => { + // checkpoints is created daily, so 2048 is enough for 6 years at least + const maxCheckpontSize = 2048 + + let size = 1 + while (size <= maxCheckpontSize) { + await setUpCheckpointsUpTo(size) + + console.log( + 'findCheckpointHints([1], 1, checkpointsSize): Gas spent:', + await withdrawalQueue.findCheckpointHints.estimateGas([1], 1, size), + 'tokenURI(1): Gas spent:', + await withdrawalQueue.tokenURI.estimateGas(1), + 'checkpoints size: ', + size + ) + size = size * 2 + } + }).timeout(0) + + async function setUpCheckpointsUpTo(n) { + for (let i = await withdrawalQueue.getLastCheckpointIndex(); i < n; i++) { + await withdrawalQueue.requestWithdrawals([StETH(0.00001)], user, { from: user }) + await withdrawalQueue.finalize([await withdrawalQueue.getLastRequestId()], shareRate(300), { + from: daoAgent, + value: ETH(0.00001), + }) + } + + assert.equals(await withdrawalQueue.getLastCheckpointIndex(), n, 'last checkpoint index') + } +}) diff --git a/test/0.8.9/withdrawal-queue-deploy.test.js b/test/0.8.9/withdrawal-queue-deploy.test.js index 075d93ed3..5e4d7b17c 100644 --- a/test/0.8.9/withdrawal-queue-deploy.test.js +++ b/test/0.8.9/withdrawal-queue-deploy.test.js @@ -12,7 +12,7 @@ const NFTDescriptorMock = artifacts.require('NFTDescriptorMock.sol') const QUEUE_NAME = 'Unsteth nft' const QUEUE_SYMBOL = 'UNSTETH' -const NFT_DESCRIPTOR_BASE_URI = 'https://exampleDescriptor.com/' +const NFT_DESCRIPTOR_BASE_URI = 'https://exampleDescriptor.com' async function deployWithdrawalQueue({ stethOwner, diff --git a/test/0.8.9/withdrawal-queue-nft.test.js b/test/0.8.9/withdrawal-queue-nft.test.js index 38fe7663d..2068623ff 100644 --- a/test/0.8.9/withdrawal-queue-nft.test.js +++ b/test/0.8.9/withdrawal-queue-nft.test.js @@ -97,7 +97,7 @@ contract('WithdrawalQueue', ([owner, stranger, daoAgent, user, tokenUriManager, context('tokenURI', () => { const requestId = 1 - const baseTokenUri = 'https://example.com/' + const baseTokenUri = 'https://example.com' beforeEach(async function () { await withdrawalQueue.requestWithdrawals([ETH(25), ETH(25)], user, { from: user }) @@ -106,7 +106,42 @@ contract('WithdrawalQueue', ([owner, stranger, daoAgent, user, tokenUriManager, it('returns tokenURI without nftDescriptor', async () => { const tx = await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager }) assert.emits(tx, 'BaseURISet', { baseURI: baseTokenUri }) - assert.equals(await withdrawalQueue.tokenURI(1), `${baseTokenUri}${requestId}`) + + assert.equals( + await withdrawalQueue.tokenURI(1), + `${baseTokenUri}/${requestId}?requested=${ETH(25)}&created_at=${ + (await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp + }` + ) + }) + + it('correct tokenURI after finalization', async () => { + const tx = await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager }) + assert.emits(tx, 'BaseURISet', { baseURI: baseTokenUri }) + + await withdrawalQueue.finalize([1], shareRate(300), { from: daoAgent, value: ETH(25) }) + + assert.equals( + await withdrawalQueue.tokenURI(1), + `${baseTokenUri}/${requestId}?requested=${ETH(25)}&created_at=${ + (await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp + }&finalized=${(await withdrawalQueue.getClaimableEther([1], [1]))[0]}` + ) + }) + + it('correct tokenURI after finalization with discount', async () => { + const tx = await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager }) + assert.emits(tx, 'BaseURISet', { baseURI: baseTokenUri }) + + const batch = await withdrawalQueue.prefinalize([1], shareRate(1)) + await withdrawalQueue.finalize([1], shareRate(1), { from: daoAgent, value: batch.ethToLock }) + + assert.equals( + await withdrawalQueue.tokenURI(1), + `${baseTokenUri}/${requestId}?requested=${ETH(25)}&created_at=${ + (await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp + }&finalized=${batch.sharesToBurn}` + ) }) it('returns tokenURI without nftDescriptor and baseUri', async () => { @@ -595,22 +630,17 @@ contract('WithdrawalQueue', ([owner, stranger, daoAgent, user, tokenUriManager, }) it('should mint with tokenURI', async () => { - const tx = await withdrawalQueue.requestWithdrawals([ETH(25), ETH(25)], user, { from: user }) - assert.emits(tx, 'Transfer', { - from: ZERO_ADDRESS, - to: user, - tokenId: 1, - }) - assert.emits(tx, 'Transfer', { - from: ZERO_ADDRESS, - to: user, - tokenId: 2, - }) - await withdrawalQueue.setBaseURI('https://example.com/', { from: tokenUriManager }) + await withdrawalQueue.requestWithdrawals([ETH(25), ETH(25)], user, { from: user }) + await withdrawalQueue.setBaseURI('https://example.com', { from: tokenUriManager }) assert.equals(await withdrawalQueue.balanceOf(user), 2) assert.equals(await withdrawalQueue.ownerOf(1), user) - assert.equals(await withdrawalQueue.tokenURI(1), 'https://example.com/1') + assert.equals( + await withdrawalQueue.tokenURI(1), + `https://example.com/1?requested=25000000000000000000&created_at=${ + (await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp + }` + ) }) it('should mint with nftDescriptor', async () => {