Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC4906 implementation for withdrawal nft #733

Merged
merged 9 commits into from
Apr 12, 2023
26 changes: 6 additions & 20 deletions contracts/0.8.9/WithdrawalQueue.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
22 changes: 9 additions & 13 deletions contracts/0.8.9/WithdrawalQueueBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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);

Expand Down Expand Up @@ -364,7 +359,7 @@ abstract contract WithdrawalQueueBase {
_amountOfETH,
requestToFinalize.cumulativeShares - lastFinalizedRequest.cumulativeShares,
block.timestamp
);
);
}

/// @dev creates a new `WithdrawalRequest` in the queue
Expand Down Expand Up @@ -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
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
/// @return claimableEther discounted eth for `_requestId`
function _calculateClaimableEther(WithdrawalRequest storage _request, uint256 _requestId, uint256 _hint)
internal
view
Expand Down Expand Up @@ -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;

Expand Down
78 changes: 64 additions & 14 deletions contracts/0.8.9/WithdrawalQueueERC721.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -18,22 +19,18 @@ 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);
}

/// @title NFT implementation on top of {WithdrawalQueue}
/// 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;
Expand All @@ -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");

Expand Down Expand Up @@ -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);
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
}

/// @dev Se_toBytes321Metadata-name}.
Expand All @@ -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);
}
}

Expand All @@ -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) {
Expand All @@ -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 {
TheDZhon marked this conversation as resolved.
Show resolved Hide resolved
_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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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),
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
bytes("/"),
bytes(_requestId.toString()),
bytes("?requested="),
bytes(
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
22 changes: 22 additions & 0 deletions contracts/0.8.9/interfaces/IERC4906.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: 2023 OpenZeppelin, Lido <[email protected]>
// SPDX-License-Identifier: MIT
folkyatina marked this conversation as resolved.
Show resolved Hide resolved

// 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);
}
64 changes: 64 additions & 0 deletions test/0.8.9/withdrawal-nft-gas.test.js
Original file line number Diff line number Diff line change
@@ -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')
}
})
2 changes: 1 addition & 1 deletion test/0.8.9/withdrawal-queue-deploy.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading