ERC: Tokenizing Withdrawal Claims for Async 4626 Vaults

Summary

I’d like to propose a minimal standard for ERC-4626 vaults that return non-fungible receipts on withdraw / redeem, with the eventual claim happening as a separate operation against the receipt itself.

Async vaults are increasingly common, and the way users see their pending exits matters. Today, an async exit is either invisible (a server-tracked request) or non-composable (a request ID keyed by (controller, requestId) à la ERC-7540). A receipt NFT is a familiar, wallet-visible, composable representation of “I have a pending claim on this vault.”

Apyx will be the first production implementation, deploying on Ethereum mainnet within the next few weeks with hundreds of millions of dollars managed in this the approach. The full design spec, reference implementation, and test suite will be open-sourced.


Motivation

ERC-4626 assumes synchronous redeem: shares in, assets out, one transaction. Real-world vaults increasingly violate this assumption: vaults with lockup periods, vaults with liquidity buffers that need to unwind underlying positions, and vaults that need to discourage exit timing attacks all need some form of asynchronous exit.

ERC-7540 addresses this with a request/claim flow keyed by request IDs and an operator/controller model. It’s a solid standard for institutional and programmatic flows, but the request itself is not a first-class entity on chain. A user with a pending 7540 request has no representation of that claim in their wallet, can’t see it on Etherscan as a token, and can’t compose it with other onchain primitives.

The receipt-NFT model targets this gap. When a user calls redeem, the vault burns their shares and mints them an ERC-721 NFT representing their pending claim. The receipt is a first-class primitive: it shows up in wallets, has a tokenURI, can be locked or transferred per implementation policy, and can be composed with other contracts (used as collateral, wrapped, used in a marketplace, etc.).

This is not a replacement for 7540, they are suited to different use cases. 7540 fits programmatic flows where the request state is operational. Receipts fit retail flows where the pending position should be visible and composable as a primitive.


Proposed Interfaces

Per-vault behavior lives in opt-in extensions discovered via ERC-165.

/// @notice Core receipt: a non-fungible representation of a pending claim
/// against an issuing vault or protocol.
interface IReceipt is IERC721, IERC165 {
    /// @notice Emitted when a receipt is minted. Indexers MUST use this to
    /// associate a receipt with its issuer (msg.sender) and recipient.
    /// Implementations MUST emit this on every mint.
    event ReceiptMinted(
        address indexed minter,
        address indexed to,
        uint256 indexed tokenId,
        uint256 amount
    );

    /// @notice Emitted on successful claim. Implementations MUST emit this on
    /// every successful call to claim().
    event ReceiptClaimed(
        address indexed owner,
        address indexed receiver,
        uint256 indexed tokenId,
        uint256 amount
    );

    /// @notice Burn the receipt and pay out the underlying entitlement.
    /// @param tokenId The receipt to claim.
    /// @param receiver Address that receives the entitlement.
    /// @return amount Net amount transferred to receiver.
    function claim(uint256 tokenId, address receiver) external returns (uint256 amount);

    /// @notice Net amount a claim would yield if executed now.
    function previewClaim(uint256 tokenId) external view returns (uint256 amount);

    /// @notice Whether claim(tokenId, ownerOf(tokenId)) would succeed in the current block.
    /// MAY return true based on any condition: time, block number, TVL reached,
    /// external oracle, etc. The decoupling of isClaimable from time-based extensions
    /// is intentional: not every receipt's claimability is a function of time.
    function isClaimable(uint256 tokenId) external view returns (bool);

    /// @notice The token (or other entitlement) units that claim() will pay out for this tokenId.
    /// Simple implementations return a constant address; complex receipts that hold
    /// multiple asset types MAY return different addresses per tokenId.
    function unitsOf(uint256 tokenId) external view returns (address);
}

Vault-side interface

/// @notice ERC-4626 vault that produces a receipt NFT on withdraw/redeem.
interface IERC4626Receipt is IERC4626, IERC165 {
    /// @notice Emitted when a receipt is issued on withdraw/redeem.
    /// Implementations MUST emit this in addition to the standard ERC-4626 Withdraw event.
    event ReceiptIssued(
        address indexed owner,
        address indexed receiver,
        uint256 indexed tokenId,
        uint256 amount
    );

    /// @notice Returns the receipt contract for this vault.
    function receipt() external view returns (IReceipt);

    /// @notice Like ERC-4626 withdraw, but also returns the receipt tokenId for
    /// atomic composition within the same transaction.
    function withdrawForReceipt(uint256 assets, address receiver, address owner)
        external
        returns (uint256 shares, uint256 tokenId);

    /// @notice Like ERC-4626 redeem, but also returns the receipt tokenId.
    function redeemForReceipt(uint256 shares, address receiver, address owner)
        external
        returns (uint256 assets, uint256 tokenId);
}

Implementations MUST implement ERC-165. Integrations SHOULD detect support via supportsInterface(type(IERC4626Receipt).interfaceId) before assuming synchronous redeem semantics.

previewWithdraw and previewRedeem on an IERC4626Receipt vault MUST return the amount that will be escrowed in the receipt, not the eventual claim amount. The receipt’s previewClaim is the appropriate view for the latter.

Extensions (ERC-165 discoverable)

/// @notice Receipt that becomes claimable after a specific timestamp.
interface IReceiptClaimableAfter is IReceipt {
    function claimableAfter(uint256 tokenId) external view returns (uint256 timestamp);
}

/// @notice Receipt that expires at a specific timestamp.
interface IReceiptClaimableBefore is IReceipt {
    function claimableBefore(uint256 tokenId) external view returns (uint256 timestamp);
}

/// @notice Receipt that can be cancelled, returning the position to the issuer.
/// Cancellation is the inverse of claim: it gives up the eventual claim entitlement
/// in exchange for an immediate return of the original (or current-equivalent) position.
interface IReceiptCancellable is IReceipt {
    event ReceiptCancelled(
        address indexed owner,
        uint256 indexed tokenId,
        uint256 amount
    );

    function cancel(uint256 tokenId) external returns (uint256 returnedAmount);
    function isCancellable(uint256 tokenId) external view returns (bool);
}

/// @notice Receipt that charges a fee on claim. The fee is denominated in the
/// same units as the receipt's amount (per unitsOf(tokenId)).
interface IReceiptWithFee is IReceipt {
    function currentFee(uint256 tokenId) external view returns (uint256 fee);
}

Key Design Decisions

Synchronous redeem and withdraw reverts

Edit: Based on feedback this has been updated to force redeem and withdraw to revert and force integraters to call the explicit redeemForReceipt and withdrawForReceipt functions.

The standard 4626 redeem(shares, receiver, owner) and withdraw(assets, receiver, owner) MUST revert to demonstrate explicit non-compliance with the ERC-4626 spec. Integrations that do not know about the receipt will revert and promp integrators to notice early, while integrations that are receipt aware call redeemForReceipt or implement IERC721Receiver and are notified of the receipt mint.

Only redeemForReceipt and withdrawForReceipt succeed. Existing 4626 consumers revert immediately and must be updated to handle the receipt model. Clean and unambiguous, but breaking.

Alternative

The standard 4626 redeem and withdraw methods transfer assets to the receipt NFT and return the number of tokens escrowed. Existing 4626 consumers don’t break; they just observe a different end state. The downside is “compatible but semantically lying”, i.e. redeem reports it gave back assets when in fact it gave back a receipt.

Claim authorization semantics

The IERC4626Receipt.claim function should allow the owner and approve operators on the ERC-721 token to claim and specify the receiver.

The amount field and unitsOf

The amount field on ReceiptIssued, ReceiptClaimed, and ReceiptCancelled is intentionally unit-agnostic. The unitsOf(tokenId)(address) view declares what the amount denominates.

Simple implementations return a constant. For example, an ERC-4626-backed receipt, unitsOf returns the vault’s asset(). More complex receipts that hold multiple underlying tokens MAY return different addresses per tokenId.

The standard does not constrain what unitsOf can return. An implementation could conceivably return an address that isn’t an ERC-20 (e.g., a future entitlement contract). The expectation is that the issuing vault and any integrators agree on the semantics off-chain.

isClaimable in core, not in an extension

A receipt’s claimability can depend on factors beyond time and block number. For example, a TVL threshold, an external oracle, a governance vote, an off-chain attestation, or some combination. Putting isClaimable in core (rather than only exposing claimableAfter) lets implementations express arbitrary readiness conditions while still providing a single canonical “can I claim now” check.

claimableAfter and claimableBefore extensions remain useful for the common time-based case, where indexers can pre-compute the claim window without needing to call the contract.

No event for “receipt became claimable”

No on-chain event is required when a receipt becomes claimable because this would require a trigger updating the receipt contract, increasing integration requirements. Indexers should compute claimability from claimableAfter / claimableBefore and current time for time based claims.

CEI ordering

Implementations MUST burn the receipt NFT before performing the asset transfer on claim. State management beyond the NFT burn (e.g., zeroing position records) is implementation-defined, but implementations SHOULD follow checks-effects-interactions to avoid reentrancy classes that may arise from non-standard underlying tokens.

Things deliberately not in the standard

  • Mint authority. Each implementation decides who can call mint (or equivalent). The standard requires that ReceiptMinted be emitted so the issuer is observable but does not constrain access control.
  • Fees. Fees are not required, but are supported and discoverable. Fee implementation and destination are up to the vault and receipt implementation.
  • Stale position sweeps. Whether an unclaimed receipt eventually becomes void or sweepable is implementation-defined. IReceiptClaimableBefore is the standard way to express expiration; what happens to expired receipts is up to the implementer.
  • Multi-receipt-per-vault. vault.receipt() returns a single address. Vaults that want to issue different receipt types per operation can wrap multiple receipt contracts behind their own router, but the standard does not encode this.
  • Enumeration. ERC-721 enumeration remains opt-in via IERC721Enumerable. Indexers should rely on off-chain indexing of events.

Relationship to Other Standards

  • ERC-721. Core inheritance; all receipts are ERC-721.
  • ERC-165. Mandatory for IERC4626Receipt so integrations can detect async semantics.
  • ERC-4626. IERC4626Receipt extends ERC-4626 strictly. All 4626 semantics are preserved.
  • ERC-7540. Parallel standard with different use case (see Motivation). The two are not mutually exclusive and a protocol could implement both for different vault tiers.
  • ERC-5192. Receipts MAY be soulbound. Implementations that are soulbound SHOULD implement ERC-5192 directly. This standard does not conflict.
  • ERC-4906. Receipts whose metadata changes over the lifecycle (mint, become claimable, claim, cancel) SHOULD emit MetadataUpdate at appropriate points.
  • ERC-7572. Receipts SHOULD implement contractURI() for collection-level metadata, especially when wallet-visible.
  • ERC-5095 (Principal Tokens). Different problem space (fixed-term yield with fungibility). Receipts are non-fungible per-position.

Reference Implementation

Apyx will deploy this as the UnlockReceipt contract on Ethereum mainnet in the coming weeks and a minimal reference implementation, without the Apyx-specific logic, will accompany the EIP itself. The full Apyx contracts will be a real-world example.

2 Likes

In the case of a IReceiptClaimableAfter and IReceiptClaimableBefore, what happens is the expected process flow for if a user misses the redemption window? Is it expected that they can call withdraw on the vault again to receive a new receipt? I suppose this is up to the actual vault implementation

I believe that given that redeem will no longer ensure a transfer of funds of amount assets according to its signature function redeem(uint256 shares, address receiver, address owner) public returns (uint256 assets) , that it’s best not to say that this is ERC-4262 compliant.

Other developers could implement this, assume it’s ERC-4262 complaint and then enforce certain conditions in their code such as slippage protection require(assets >= expectedAmount) or other downstream processes that could be triggered from the expected movement of assets.

That’s correct, the receipt implementation and requirements for redemption are up to the vault implementer. The purpose of IReceiptClaimableAfter and IReceiptClaimableBefore is to standardize how receipt signal a redemption window to improve UX.

This is a really good call out. The truth is that a lot of flows would continue to work, but flows that depend on assets being transferred to the caller synchronously after withdraw/redeem would fail. You’re right, and the proposal could include IERC4626Deposit and IERC4626Withdrawal to allow vaults to signal that they support a subset of ERC-4626.

I’ll update the proposal to include this feedback, thank you!

One thing I’m not seeing specified is the authorization semantics of claim(tokenId, receiver). Should the standard require msg.sender to be ownerOf(tokenId) or an approved ERC-721 operator? Or is permissionless claiming intended, with payout constrained to the current owner? Since the receipt NFT is meant to represent ownership of the claim, I think this needs to be explicit for wallets, marketplaces, and collateral integrations. Very cool idea though, although I think this is making an opinionated tradeoff given current 4626 expectations

This is a really good point, and something I will include in the formal EIP submission. The intent is for claim(tokenId, receiver) to be an approved ERC-721 operator.

I agree, this effectively breaks current 4626 withdraw/redeem expectations, but I think that is required for any asynchronous withdrawal flow that attempts to conform to 4626. I am considering proposing another ERC to standardize interfaces for a subsets of 4626 to allow vault to declare partial compliance with 4626 but I’m not sure if this really necessary.

For example, IERC4626Deposit and IERC4626Withdraw that can be detected via ERC-165.

Integrations that do not know about the receipt see a successful redeem and integrations that are receipt aware call redeemForReceipt or implement IERC721Receiver and are notified at receipt mint.

On the “compatible but semantically lying” redeem behavior: what is the rationale for returning the escrowed/estimated asset amount rather than 0 ?

My concern is that integrating contracts may treat the returned assets as assets actually received by the current transaction/contract. But the actual asset transfer only happens later via claim, and there does not seem to be an enforceable invariant that claim(tokenId) will return exactly the same amount reported earlier by redeem.

Should the same compatibility rule be stated explicitly for withdraw? If withdraw(assets, receiver, owner) succeeds but transfers a receipt NFT instead of assets, it seems to create the same ERC-4626 compatibility issue as redeem

Should the same compatibility rule be stated explicitly for withdraw?

In this case the redeem method informs the caller the amount of assets that are escrowed into the receipt, regardless of any fees that are applied by the receipt at claim. This supports routers that provide slippage protection. Contracts that rely on transferring the redeemed assets would revert with an error on transfer.

Yes, the withdraw method should function in the same fashion as redeem. I’ll update my original post to include this.

@antoncoding what do you think about requiring withdraw and redeem to revert to ensure callers are aware of the receipt implementation? Callers would need to use the withdrawForReceipt and redeemForReceipt methods for the same purpose.

I’d prefer requiring standard withdraw / redeem to revert for receipt-producing exits.

ERC-7540 is a useful precedent in the sense that it makes the async semantic break explicit rather than silently preserving old ERC-4626 expectations. For this receipt model, the explicit async path is withdrawForReceipt / redeemForReceipt, and the later settlement path is claim(tokenId, receiver).

That gives integrations a clear opt-in boundary and avoids old ERC-4626 callers seeing a successful call with non-ERC-4626 postconditions.

1 Like

I have done something similar but built ontop of ERC7540 where it exposes the illiquid async requests as ERC6909 tokens which can then be traded on marketplaces.

1 Like

I’ve update the initial post to revert on redeem or withdraw to demonstrate explicit non-compliance with the ERC-4626 spec and prompt integrators to notice early.

This is interesting, thanks for sharing! I’ll respond in your thread.