diff --git a/ERCS/erc-7540.md b/ERCS/erc-7540.md index fe35babb4e..15be6e1cca 100644 --- a/ERCS/erc-7540.md +++ b/ERCS/erc-7540.md @@ -15,7 +15,7 @@ requires: 20, 165, 4626 The following standard extends [ERC-4626](./eip-4626.md) by adding support for asynchronous deposit and redemption flows. The async flows are called Requests. -New methods are added to asynchronously Request a deposit or redemption, and view the pending status of the Request. The existing `deposit`, `mint`, `withdraw`, and `redeem` ERC-4626 methods are used for executing Claimable Requests. +New methods are added to asynchronously Request a deposit or redemption, and view the status of the Request. The existing `deposit`, `mint`, `withdraw`, and `redeem` ERC-4626 methods are used for executing Claimable Requests. Implementations can choose to whether to add asynchronous flows for deposits, redemptions, or both. @@ -33,12 +33,11 @@ This standard expands the utility of ERC-4626 Vaults for asynchronous use cases. The existing definitions from [ERC-4626](./eip-4626.md) apply. In addition, this spec defines: -- Request: a function call that initiates an asynchronous deposit/redemption flow +- Request: a request to enter (`requestDeposit`) or exit (`requestRedeem`) the Vault - Pending: the state where a Request has been made but is not yet Claimable - Claimable: the state where a Request is processed by the Vault enabling the user to claim corresponding `shares` (for async deposit) or `assets` (for async redeem) - Claimed: the state where a Request is finalized by the user and the user receives the output token (e.g. `shares` for a deposit Request) - Claim function: the corresponding Vault method to bring a Request to Claimed state (e.g. `deposit` or `mint` claims `shares` from `requestDeposit`). Lower case claim always describes the verb action of calling a Claim function. -- operator: the account specified by the sender of the Request which has the right to claim a given Request once it is Claimable - asynchronous deposit Vault: a Vault that implements asynchronous Requests for deposit flows - asynchronous redemption Vault: a Vault that implements asynchronous redemption flows - fully asynchronous Vault: a Vault that implements asynchronous Requests for both deposit and redemption @@ -57,7 +56,7 @@ Asynchronous deposit Vaults MUST override the ERC-4626 specification as follows: Asynchronous redeem Vaults MUST override the ERC-4626 specification as follows: 1. The `redeem` and `withdraw` methods do not transfer `shares` to the Vault, because this already happened on `requestRedeem`. -2. The `owner/operator` field of `redeem` and `withdraw` MUST be `msg.sender` to prevent the theft of requested redemptions by a non-owner/operator. +2. The `owner` field of `redeem` and `withdraw` MUST be `msg.sender` to prevent the theft of requested redemptions by a non-owner. 3. `previewRedeem` and `previewWithdraw` MUST revert for all callers and inputs. ### Request Lifecycle @@ -66,9 +65,11 @@ After submission, Requests go through Pending, Claimable, and Claimed stages. An | **State** | **User** | **Vault** | |-------------|---------------------------------|-----------| -| Pending | `requestDeposit(assets, operator)` | `asset.transferFrom(msg.sender, vault, assets)`; `pendingDepositRequest[operator] += assets` | -| Claimable | | *Internal Request fulfillment*: `pendingDepositRequest[msg.sender] -= assets`; `maxDeposit[operator] += assets` | -| Claimed | `deposit(assets, receiver)` | `maxDeposit[msg.sender] -= assets`; `vault.balanceOf[receiver] += shares` | +| Pending | `requestDeposit(assets, receiver, owner, data)` | `asset.transferFrom(owner, vault, assets)`; `pendingDepositRequest[receiver] += assets` | +| Claimable | | *Internal Request fulfillment*: `pendingDepositRequest[owner] -= assets`; `claimableDepositRequest[owner] += assets` | +| Claimed | `deposit(assets, receiver)` | `claimableDepositRequest[owner] -= assets`; `vault.balanceOf[receiver] += shares` | + +Note that `maxDeposit` increases and decreases in sync with `claimableDepositRequest`. An important Vault inequality is that following a Request(s), the cumulative requested quantity MUST be more than `pendingDepositRequest + maxDeposit - claimed`. The inequality may come from fees or other state transitions outside implemented by Vault logic such as cancellation of a Request, otherwise this would be a strict equality. @@ -76,19 +77,37 @@ Requests MUST NOT skip or otherwise short-circuit the Claim state. In other word For asynchronous Vaults, the exchange rate between `shares` and `assets` including fees and yield is up to the Vault implementation. In other words, pending redemption Requests MAY NOT be yield bearing and MAY NOT have a fixed exchange rate. +### Request Ids +The request ID (`requestId`) of a request is returned by the corresponding `requestDeposit` and `requestRedeem` functions. + +Multiple requests may have the same `requestId`, so a given Request is discriminated by both the `requestId` and the `owner`. + +Requests of the same `requestId` MUST be fungible with each other (except in the special case `requestId == 0` described below). I.e. all Requests with the same `requestId` MUST transition from Pending to Claimable at the same time and receive the same exchange rate between `assets` and `shares`. + +If a Request becomes partially claimable, all requests of the same `requestId` MUST become claimable at the same pro rata rate. + +There are no assumptions or requirements of requests with different `requestId`. I.e. they MAY transition to Claimable at different times and exchange rates with no ordering or correlation enforced in any way. + +When `requestId==0`, the Vault MUST use purely the `owner` to discriminate the request state. The Pending and Claimable state of multiple requests from the same `owner` would be aggregated. If a Vault returns `0` for the `requestId` of any request, it MUST return `0` for all requests. ### Methods #### requestDeposit -Transfers `assets` from `msg.sender` into the Vault and submits a Request for asynchronous `deposit/mint`. This places the Request in Pending state, with a corresponding increase in `pendingDepositRequest` for the amount `assets`. +Transfers `assets` from `msg.sender` into the Vault and submits a Request for asynchronous `deposit`. This places the Request in Pending state, with a corresponding increase in `pendingDepositRequest` for the amount `assets`. + +The output `requestId` is used to partially discriminate the request along with the `receiver`. See [Request Ids](#request-ids) section for more info. -When the Request is Claimable, `maxDeposit` and `maxMint` will be increased for the case where the `receiver` input is the `operator`. `deposit` or `mint` can subsequently be called by `operator` to receive `shares`. A Request MAY transition straight to Claimable state but MUST NOT skip the Claimable state. +If the length of `data` is not 0, the Request MUST send an `onERC7540DepositReceived` callback to `receiver` following the interface of `ERC7540DepositReceiver` described in [Request Callbacks](#request-callbacks) section. If the length of `data` is 0, the Request MUST NOT send a callback. + +When the Request is Claimable, `claimableDepositRequest` will be increased for the `receiver`. `deposit` or `mint` can subsequently be called by `receiver` to receive `shares`. A Request MAY transition straight to Claimable state but MUST NOT skip the Claimable state. The `shares` that will be received on `deposit` or `mint` MAY NOT be equivalent to the value of `convertToShares(assets)` at the time of Request, as the price can change between Request and Claim. MUST support [ERC-20](./eip-20.md) `approve` / `transferFrom` on `asset` as a deposit Request flow. +`owner` MUST equal `msg.sender` unless the `owner` has approved the `msg.sender` by some mechanism. + MUST revert if all of `assets` cannot be requested for `deposit`/`mint` (due to deposit limit being reached, slippage, the user not approving enough underlying tokens to the Vault contract, etc). Note that most implementations will require pre-approval of the Vault with the Vault's underlying `asset` token. @@ -103,13 +122,20 @@ MUST emit the `RequestDeposit` event. inputs: - name: assets type: uint256 - - name: operator + - name: receiver + type: address + - name: owner type: address + - name: data + type: bytes + outputs: + - name: requestId + type: uint256 ``` #### pendingDepositRequest -The amount of requested `assets` in Pending state for the `operator` to `deposit` or `mint`. +The amount of requested `assets` in Pending state for the `owner` to `deposit` or `mint`. MUST NOT include any `assets` in Claimable state for `deposit` or `mint`. @@ -123,7 +149,31 @@ MUST NOT revert unless due to integer overflow caused by an unreasonably large i stateMutability: view inputs: - - name: operator + - name: owner + type: address + + outputs: + - name: assets + type: uint256 +``` + +#### claimableDepositRequest + +The amount of requested `assets` in Claimable state for the `owner` to `deposit` or `mint`. + +MUST NOT include any `assets` in Pending state for `deposit` or `mint`. + +MUST NOT show any variations depending on the caller. + +MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + +```yaml +- name: claimableDepositRequest + type: function + stateMutability: view + + inputs: + - name: owner type: address outputs: @@ -133,7 +183,9 @@ MUST NOT revert unless due to integer overflow caused by an unreasonably large i #### requestRedeem -Assumes control of `shares` from `owner` and submits a Request for asynchronous `redeem/withdraw`. This places the Request in Pending state, with a corresponding increase in `pendingRedeemRequest` for the amount `shares`. +Assumes control of `shares` from `owner` and submits a Request for asynchronous `redeem`. This places the Request in Pending state, with a corresponding increase in `pendingRedeemRequest` for the amount `shares`. + +The output `requestId` is used to partially discriminate the request along with the `receiver`. See [Request Ids](#request-ids) section for more info. MAY support either a locking or a burning mechanism for `shares` depending on the Vault implemention. @@ -141,7 +193,9 @@ If a Vault uses a locking mechanism for `shares`, those `shares` MUST be burned MUST support a redeem Request flow where the control of `shares` is taken from `owner` directly where `msg.sender` has ERC-20 approval over the `shares` of `owner`. -When the Request is Claimable, `maxRedeem` and `maxWithdraw` will be increased for the case where the `owner` input is the `operator`. `redeem` or `withdraw` can subsequently be called by `operator` to receive `assets`. A Request MAY transition straight to Claimable state but MUST NOT skip the Claimable state. +If the length of `data` is not 0, the Request MUST send an `onERC7540RedeemReceived` callback to `receiver` following the interface of `ERC7540RedeemReceiver` described in [Request Callbacks](#request-callbacks) section. If the length of `data` is 0, the Request MUST NOT send a callback. + +When the Request is Claimable, `claimableRedeemRequest` will be increased for the `receiver`. `redeem` or `withdraw` can subsequently be called by `receiver` to receive `assets`. A Request MAY transition straight to Claimable state but MUST NOT skip the Claimable state. The `assets` that will be received on `redeem` or `withdraw` MAY NOT be equivalent to the value of `convertToAssets(shares)` at time of Request, as the price can change between Pending and Claimed. @@ -159,15 +213,20 @@ MUST emit the `RequestRedeem` event. inputs: - name: shares type: uint256 - - name: operator + - name: receiver type: address - name: owner type: address + - name: data + type: bytes + outputs: + - name: requestId + - type: uint256 ``` #### pendingRedeemRequest -The amount of requested `shares` in Pending state for the `operator` to `redeem` or `withdraw`. +The amount of requested `shares` in Pending state for the `owner` to `redeem` or `withdraw`. MUST NOT include any `shares` in Claimable state for `redeem` or `withdraw`. @@ -181,7 +240,31 @@ MUST NOT revert unless due to integer overflow caused by an unreasonably large i stateMutability: view inputs: - - name: operator + - name: owner + type: address + + outputs: + - name: shares + type: uint256 +``` + +#### claimableRedeemRequest + +The amount of requested `shares` in Claimable state for the `owner` to `redeem` or `withdraw`. + +MUST NOT include any `shares` in Pending state for `redeem` or `withdraw`. + +MUST NOT show any variations depending on the caller. + +MUST NOT revert unless due to integer overflow caused by an unreasonably large input. + +```yaml +- name: claimableRedeemRequest + type: function + stateMutability: view + + inputs: + - name: owner type: address outputs: @@ -193,7 +276,7 @@ MUST NOT revert unless due to integer overflow caused by an unreasonably large i #### DepositRequest -`sender` has locked `assets` in the Vault to Request a deposit. `operator` controls this Request. +`owner` has locked `assets` in the Vault to Request a deposit with request ID `requestId`. `receiver` controls this Request. `sender` is the caller of the `requestDeposit` which may not be equal to the `owner`. MUST be emitted when a deposit Request is submitted using the `requestDeposit` method. @@ -202,11 +285,17 @@ MUST be emitted when a deposit Request is submitted using the `requestDeposit` m type: event inputs: - - name: sender + - name: receiver indexed: true type: address - - name: operator + - name: owner + indexed: true + type: address + - name: requestId indexed: true + type: uint256 + - name: sender + indexed: false type: address - name: assets indexed: false @@ -215,7 +304,7 @@ MUST be emitted when a deposit Request is submitted using the `requestDeposit` m #### RedeemRequest -`sender` has locked `shares`, owned by `owner`, in the Vault to Request a redemption. `operator` controls this Request. +`sender` has locked `shares`, owned by `owner`, in the Vault to Request a redemption. `receiver` controls this Request, but is not necessarily the `owner`. MUST be emitted when a redemption Request is submitted using the `requestRedeem` method. @@ -224,30 +313,103 @@ MUST be emitted when a redemption Request is submitted using the `requestRedeem` type: event inputs: - - name: sender - indexed: true - type: address - - name: operator + - name: receiver indexed: true type: address - name: owner indexed: true type: address + - name: requestId + indexed: true + type: uint256 + - name: sender + indexed: false + type: uint256 - name: assets indexed: false type: uint256 ``` +### Request Callbacks + +All methods which initiate a request (including `requestId==0`) include a `data` parameter, which if nonzero length MUST send a callback to the receiver. + +There are two interfaces, `ERC7540DepositReceiver` and `ERC7540RedeemReceiver` which each define the single callback method to be called. + +#### `ERC7540DepositReceiver` +The interface to be called on `requestDeposit`. + +`operator` is the `msg.sender` of the original `requestDeposit` call. `owner` is the `owner` of the `requestDeposit`. `requestId` is the output `requestId` of the `requestDeposit` and `data` is the `data` of the `requestDeposit`. + +This function MUST return `0xe74d2a41` upon successful execution of the callback. + +```yaml +- name: onERC7540DepositReceived + type: function + + inputs: + - name: operator + type: address + - name: owner + type: address + - name: requestId + type: uint256 + - name: data + type: bytes + outputs: + - name: interfaceId + type: bytes4 +``` + + +#### `ERC7540RedeemReceiver` +The interface to be called on `requestRedeem`. + +`operator` is the `msg.sender` of the original `requestRedeem` call. `owner` is the `owner` of the `requestRedeem`. `requestId` is the output `requestId` of the `requestRedeem` and `data` is the `data` of the `requestRedeem`. + +This function MUST return `0x0102fde4` upon successful execution of the callback. + +```yaml +- name: onERC7540RedeemReceived + type: function + + inputs: + - name: operator + type: address + - name: owner + type: address + - name: requestId + type: uint256 + - name: data + type: bytes + outputs: + - name: interfaceId + type: bytes4 +``` + ### [ERC-165](./eip-165.md) support -Smart contracts implementing this standard MUST implement the [ERC-165](./eip-165.md) `supportsInterface` function. +Smart contracts implementing this Vault standard MUST implement the [ERC-165](./eip-165.md) `supportsInterface` function. -Asynchronous deposit Vaults MUST return the constant value `true` if `0xea446681` is passed through the `interfaceID` argument. +Asynchronous deposit Vaults MUST return the constant value `true` if `0x1683f250` is passed through the `interfaceID` argument. -Asynchronous redemption Vaults MUST return the constant value `true` if `0x2e9dd5bd` is passed through the `interfaceID` argument. +Asynchronous redemption Vaults MUST return the constant value `true` if `0x0899cb0b` is passed through the `interfaceID` argument. ## Rationale +### Including Request IDs but Not Including a Claim by ID method +Requests in an Asynchronous Vault have properties of NFTs or Semi-Fungible tokens due to their asynchronicity. However, trying to pigeonhole all ERC-7540 Vaults into supporting [ERC-721](./eip-721) or [ERC-1155](./erc-1155) for Requests would create too much interface bloat. + +Using both an id and address to discriminate Requests allows for any of these use cases to be developed at an external layer without adding too much complexity to the core interface. + +Certain Vaults especially `requestId==0` cases benefit from using the underlying [ERC-4626](./eip-4626) methods for claiming because there is no discrimination at the `requestId` level. This standard is written primarily with those use cases in mind. A future standard can optimize for nonzero request ID with support for claiming and transferring requests discriminated also with an `requestId`. + +### Callbacks + +Callbacks on Request calls can be used among other things to allow Requests to become fully [ERC-721](./eip-721) or [ERC-1155](./erc-1155) compatible in an external layer. + +This can support flows where a smart contract manages the Request lifecycle on behalf of a user. + ### Symmetry and Non-inclusion of requestWithdraw and requestMint In ERC-4626, the spec was written to be fully symmetrical with respect to converting `assets` and `shares` by including deposit/withdraw and mint/redeem. @@ -284,10 +446,6 @@ The 2-step approach used in the standard may be abstracted into a 1-step approac In the case where a Request may become Claimable immediately in the same block, there can be router contracts which atomically check for Claimable amounts immediately upon Request. Frontends can dynamically route Requests in this way depending on the state and implementation of the Vault to handle this edge case. -### Operator function parameter on requestDeposit and requestRedeem - -To support flows where a smart contract manages the Request lifecycle on behalf of a user, the `operator` parameter is included in the `requestDeposit` and `requestRedeem` functions. This is not called `owner` because the `assets` or `shares` are not transferred from this account on Request submission, unlike the behaviour of an `owner` on `redeem`. It is also not called `receiver` because the `shares` or `assets` are not necessarily transferred on claiming the Request, this can be chosen by the operator when they call `deposit`, `mint`, `redeem`, or `withdraw`. - ### No Outputs for Request functions `requestDeposit` and `requestRedeem` may not have a known exchange rate that will happen when the Request becomes Claimed. Returning the corresponding `assets` or `shares` could not work in this case. @@ -300,7 +458,7 @@ The state transition of a Request from Pending to Claimable happens at the Vault ### Reversion of Preview Functions in Async Request Flows -The preview functions do not take an address parameter, therefore the only way to discriminate discrepancies in exchange rate are via the `msg.sender`. However, this could lead to integration/implementation complexities where support contracts cannot determine the output of a claim on behalf of an `operator`. +The preview functions do not take an address parameter, therefore the only way to discriminate discrepancies in exchange rate are via the `msg.sender`. However, this could lead to integration/implementation complexities where support contracts cannot determine the output of a claim on behalf of an `owner`. In addition, there is no on-chain benefit to previewing the Claim step as the only valid state transition is to Claim anyway. If the output of a Claim is undesirable for any reason, the calling contract can revert on the output of that function call. @@ -319,19 +477,58 @@ The interface is fully backwards compatible with [ERC-4626](./eip-4626.md). The ## Reference Implementation -WIP +```solidity + // This code snippet is incomplete pseudocode used for example only and is no way intended to be used in production or guaranteed to be secure -## Security Considerations + mapping(address => uint256) public pendingDepositRequest; + + mapping(address => uint256) public claimableDepositRequest; + + function requestDeposit(uint256 assets, address receiver, address owner, bytes calldata data) external returns (uint256 requestId) { + require(assets != 0); + require(owner == msg.sender); + + requestId = 0; // no requestId associated with this request + + asset.safeTransferFrom(owner, address(this), assets); // asset here is the Vault underlying asset + + pendingDepositRequest[owner] += assets; + + // Perform the callback + if (data.length != 0) { + require(ERC7540Receiver(receiver).onERC7540DepositReceived(msg.sender, owner, requestId, data) == ERC7540Receiver.onERC7540DepositReceived.selector, "receiver failed"); + } + + emit DepositRequest(receiver, owner, requestId, msg.sender, assets); + return requestId; + } -The methods `pendingDepositRequest` and `pendingRedeemRequest` are estimates useful for display purposes, and can be outdated due to the asynchronicity. + /** + * Include some arbitrary transition logic here from Pending to Claimable + */ -In general, asynchronicity concerns make state transitions in the Vault much more complex and vulnerable to security risks. Access control on Vault operations, clear documentation of state transitioning, and invariant checks should all be performed to mitigate these risks. + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + require(assets != 0); + + claimableDepositRequest[msg.sender] -= assets; // underflow would revert if not enough claimable assets + + shares = convertToShares(assets); // this naive example uses the instantaneous exchange rate. It may be more common to use the rate locked in upon Claimable stage. + + balanceOf[receiver] += shares; + + emit Deposit(msg.sender, receiver, assets, shares); + } + +``` + +## Security Considerations -In particular, shares or assets locked for Requests can be stuck in the Pending state. Vaults may elect to allow for fungibility of pending claims or implement some cancellation functionality to protect users. +In general, asynchronicity concerns make state transitions in the Vault much more complex and vulnerable to security risks. Access control on Vault operations, clear documentation of state transitioning, and invariant checks should all be performed to mitigate these risks. For example: -Moreover, users might not know what the final exchange rate will be on any Request due to the asynchronicity. Users therefore trust the implementation of the asynchronous Vault in the computation of the exchange rate and fulfillment of their Request. +* The view methods for viewing Pending and Claimable request states (e.g. pendingDepositRequest) are estimates useful for display purposes but can be outdated. The inability to know the final exchange rate will be on any Request requires users to trust the implementation of the asynchronous Vault in the computation of the exchange rate and fulfillment of their Request. +* Shares or assets locked for Requests can be stuck in the Pending state. Vaults may elect to allow for fungibility of pending claims or implement some cancellation functionality to protect users. -It is worth highlighting again here that the Claim functions for any asynchronous flows MUST enforce that `msg.sender == operator/owner` to prevent theft of Claimable `assets` or `shares` +Lastly, it is worth highlighting again here that the Claim functions for any asynchronous flows MUST enforce that msg.sender == owner to prevent theft of Claimable assets or shares. ## Copyright