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
24 changes: 5 additions & 19 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
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
64 changes: 51 additions & 13 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, WithdrawalQueue, IERC4906 {
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
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,7 @@ contract WithdrawalQueueERC721 is IERC721Metadata, WithdrawalQueue {
returns (bool)
{
return interfaceId == type(IERC721).interfaceId || interfaceId == type(IERC721Metadata).interfaceId
|| super.supportsInterface(interfaceId);
|| interfaceId == bytes4(0x49064906) || super.supportsInterface(interfaceId);
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
}

/// @dev Se_toBytes321Metadata-name}.
Expand All @@ -114,8 +112,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 Down Expand Up @@ -146,6 +143,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 +236,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 +349,31 @@ 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}?state=finalized|unfinalized&amount=${amount}&created_at=${timestamp}
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
// we still have no string.concat in 0.8.9, so we have to do it with bytes
bool finalized = _requestId <= getLastFinalizedRequestId();
return string(
bytes.concat(
bytes(baseURI),
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
bytes(_requestId.toString()),
bytes("?status="),
bytes(finalized ? "finalized" : "pending"),
bytes("&amount="),
bytes(
folkyatina marked this conversation as resolved.
Show resolved Hide resolved
finalized
? _getClaimableEther(_requestId, _findCheckpointHint(_requestId, 1, getLastCheckpointIndex()))
.toString()
: uint256(_getQueue()[_requestId].cumulativeStETH - _getQueue()[_requestId - 1].cumulativeStETH)
.toString()
),
bytes("&created_at="),
bytes(uint256(_getQueue()[_requestId].timestamp).toString())
)
);
}
}
21 changes: 21 additions & 0 deletions contracts/0.8.9/interfaces/IERC4906.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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);
}
41 changes: 39 additions & 2 deletions test/0.8.9/withdrawal-queue-nft.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,39 @@ contract('WithdrawalQueue', ([owner, stranger, daoAgent, user, tokenUriManager,

it('returns tokenURI without nftDescriptor', async () => {
await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager })
assert.equals(await withdrawalQueue.tokenURI(1), `${baseTokenUri}${requestId}`)
assert.equals(
await withdrawalQueue.tokenURI(1),
`${baseTokenUri}${requestId}?status=pending&amount=${ETH(25)}&created_at=${
(await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp
}`
)
})

it('correct tokenURI after finalization', async () => {
await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager })

await withdrawalQueue.finalize([1], shareRate(300), { from: daoAgent, value: ETH(25) })

assert.equals(
await withdrawalQueue.tokenURI(1),
`${baseTokenUri}${requestId}?status=finalized&amount=${
(await withdrawalQueue.getClaimableEther([1], [1]))[0]
}&created_at=${(await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp}`
)
})

it('correct tokenURI after finalization with discount', async () => {
await withdrawalQueue.setBaseURI(baseTokenUri, { from: tokenUriManager })

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}?status=finalized&amount=${batch.sharesToBurn}&created_at=${
(await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp
}`
)
})

it('returns tokenURI without nftDescriptor and baseUri', async () => {
Expand Down Expand Up @@ -466,7 +498,12 @@ contract('WithdrawalQueue', ([owner, stranger, daoAgent, user, 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?status=pending&amount=25000000000000000000&created_at=${
(await withdrawalQueue.getWithdrawalStatus([1]))[0].timestamp
}`
)
})

it('should mint with nftDescriptor', async () => {
Expand Down