ERC-7621 - Basket Token

Created 2024-02-11
Status Draft
Category ERC
Type Standards Track
Authors
  • Callum Mitchell-Clark (@AlvaraProtocol) <cal at alvara.xyz>

  • Dominic Ryder <dom at alvara.xyz>

  • Michael Ryder <mike at alvara.xyz>

Requires

Abstract

This standard defines a multi-asset basket token that extends ERC-20. A basket holds a set of ERC-20 constituent tokens at configurable target weights. The basket contract itself is the share token: holders of the basket's ERC-20 supply have a proportional claim on its underlying reserves. A designated owner, identified via ERC-173, has authority to rebalance the basket's composition.

Motivation

ERC-4626 standardized single-asset vaults for yield-bearing tokens. ERC-7575 extended that model to multi-asset vault entry points while preserving ERC-4626 semantics. However, neither standard addresses manager-rebalanced, weighted basket share tokens, where a designated owner actively adjusts the constituent set and target allocations over time.

There is no standard specifically for this use case. Every protocol that implements a weighted basket (tokenized index funds, portfolio products, treasury diversification vehicles) uses a custom interface, which creates additional integration work for wallets, aggregators, and DeFi protocols that support such baskets.

This standard provides a common interface for weighted, manager-rebalanced baskets: contributing assets, withdrawing proportionally, querying composition and target weights, and rebalancing. Unlike vault standards that primarily standardize asset entry and exit, this standard treats portfolio composition itself (constituent membership and target weights) as standardized, queryable state. Because the basket contract is itself an ERC-20 token, basket shares are compatible with existing ERC-20 infrastructure.

Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174.

Definitions

Interface

A conforming contract MUST implement ERC-20 and the ERC-20 metadata extensions (name(), symbol(), and decimals()), ERC-165, ERC-173, and the following interface. The contract's ERC-20 supply represents shares, a proportional claim on the basket's reserves. Each contract manages exactly one basket. Implementations MAY additionally implement EIP-2612 for gasless approvals.

// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.20;

/// @title ERC-7621 Basket Token Standard
/// @dev See https://eips.ethereum.org/EIPS/eip-7621
///      Conforming contracts MUST also implement IERC20, IERC165, and IERC173.
interface IERC7621 {

    // --- Errors ---

    /// @dev Array lengths do not match.
    error LengthMismatch(uint256 expected, uint256 actual);

    /// @dev Weights do not sum to 10000.
    error InvalidWeights(uint256 weightSum);

    /// @dev Amount is zero where a non-zero value is required.
    error ZeroAmount();

    /// @dev Token is not a constituent of the basket.
    error NotConstituent(address token);

    /// @dev Slippage tolerance exceeded on share minting.
    error InsufficientShares(uint256 minimum, uint256 actual);

    /// @dev Slippage tolerance exceeded on constituent withdrawal.
    error InsufficientAmount(uint256 index, uint256 minimum, uint256 actual);

    /// @dev Duplicate constituent token address.
    error DuplicateConstituent(address token);

    /// @dev Constituent address is the zero address.
    error ZeroAddress();

    // --- Events ---

    /// @notice MUST be emitted when assets are contributed to the basket.
    /// @param caller The address that called `contribute`.
    /// @param receiver The address that received the minted shares.
    /// @param lpAmount The number of shares minted.
    /// @param amounts The constituent token amounts deposited.
    event Contributed(
        address indexed caller,
        address indexed receiver,
        uint256 lpAmount,
        uint256[] amounts
    );

    /// @notice MUST be emitted when shares are burned and assets withdrawn.
    /// @param caller The address that called `withdraw`.
    /// @param receiver The address that received the constituent tokens.
    /// @param lpAmount The number of shares burned.
    /// @param amounts The constituent token amounts returned.
    event Withdrawn(
        address indexed caller,
        address indexed receiver,
        uint256 lpAmount,
        uint256[] amounts
    );

    /// @notice MUST be emitted when the constituent set or weights change.
    /// @param newTokens The new constituent token addresses.
    /// @param newWeights The new target weights in basis points.
    event Rebalanced(address[] newTokens, uint256[] newWeights);

    // --- View Functions ---

    /// @notice Returns the constituent tokens and their target weights.
    /// @dev The ordering of the returned arrays is stable between calls to
    ///      `rebalance`. The `amounts` arrays in `contribute`, `withdraw`,
    ///      and their preview counterparts MUST follow this same ordering.
    /// @return tokens Constituent addresses.
    /// @return weights Target weights in basis points, summing to 10000.
    function getConstituents()
        external view returns (address[] memory tokens, uint256[] memory weights);

    /// @notice Returns the number of constituents.
    /// @return count The number of constituent tokens.
    function totalConstituents() external view returns (uint256 count);

    /// @notice Returns the accounted reserve balance of a constituent.
    /// @dev Returns the reserve recognized by the basket for share accounting,
    ///      which MAY differ from `IERC20(token).balanceOf(address(this))`.
    /// @param token The constituent token address.
    /// @return balance The accounted reserve of `token`.
    function getReserve(address token) external view returns (uint256 balance);

    /// @notice Returns the target weight of a specific constituent.
    /// @dev MUST revert with `NotConstituent` if `token` is not a constituent.
    /// @param token The constituent token address.
    /// @return weight The target weight in basis points.
    function getWeight(address token) external view returns (uint256 weight);

    /// @notice Returns whether an address is a current constituent.
    /// @param token The token address to check.
    /// @return True if `token` is a constituent.
    function isConstituent(address token) external view returns (bool);

    /// @notice Returns the total basket value in the implementation's accounting unit.
    /// @dev The accounting unit and valuation method are implementation-defined
    ///      but MUST be deterministic and consistent with `previewContribute`.
    ///      The returned value is only meaningful within this implementation's
    ///      accounting model and MUST NOT be assumed comparable across
    ///      different basket implementations.
    /// @return value The total basket value in the implementation's unit.
    function totalBasketValue() external view returns (uint256 value);

    // --- Actions ---

    /// @notice Deposits constituent tokens and mints shares to `receiver`.
    /// @dev The caller MUST have approved this contract to spend the required
    ///      amounts of each constituent prior to calling.
    ///      `amounts` MUST be ordered to match `getConstituents`.
    ///      MUST emit `Contributed`.
    ///      MUST revert with `LengthMismatch` if `amounts.length` does not
    ///      equal `totalConstituents()`.
    ///      MUST revert with `ZeroAmount` if all amounts are zero.
    ///      MUST revert with `InsufficientShares` if shares minted is less
    ///      than `minShares`.
    ///      Shares minted MUST be monotonically non-decreasing with respect
    ///      to amounts contributed — contributing more MUST NOT yield fewer shares.
    ///      When rounding, MUST round shares minted down (favoring the basket).
    /// @param amounts Ordered array of constituent token amounts to deposit.
    /// @param receiver The address that will receive minted shares.
    /// @param minShares Minimum acceptable shares to mint. Reverts if not met.
    /// @return lpAmount Shares minted.
    function contribute(uint256[] calldata amounts, address receiver, uint256 minShares)
        external returns (uint256 lpAmount);

    /// @notice Burns shares and transfers proportional reserves to `receiver`.
    /// @dev MUST emit `Withdrawn`.
    ///      MUST revert with `ZeroAmount` if `lpAmount` is zero.
    ///      MUST revert if the caller holds fewer than `lpAmount` shares.
    ///      MUST revert with `LengthMismatch` if `minAmounts.length` does not
    ///      equal `totalConstituents()`.
    ///      MUST revert with `InsufficientAmount` if any returned amount is
    ///      less than the corresponding entry in `minAmounts`.
    ///      For each constituent: `amount_i = reserve_i * lpAmount / totalSupply`,
    ///      rounding down (favoring the basket).
    ///      Shares MUST be burned before constituent tokens are transferred out.
    /// @param lpAmount The number of shares to burn.
    /// @param receiver The address that will receive constituent tokens.
    /// @param minAmounts Minimum acceptable amounts per constituent. Reverts if not met.
    /// @return amounts Constituent amounts returned, ordered by `getConstituents`.
    function withdraw(uint256 lpAmount, address receiver, uint256[] calldata minAmounts)
        external returns (uint256[] memory amounts);

    /// @notice Updates the constituent set and target weights.
    /// @dev MUST revert if caller is not `owner()` per ERC-173.
    ///      MUST revert with `LengthMismatch` if array lengths differ.
    ///      MUST revert with `InvalidWeights` if weights do not sum to 10000.
    ///      MUST revert with `DuplicateConstituent` if `newTokens` contains duplicates.
    ///      MUST revert with `ZeroAddress` if any entry in `newTokens` is `address(0)`.
    ///      MUST emit `Rebalanced`.
    ///      The standardized effect of this function is updating the constituent
    ///      set and target weights. Any reserve realignment (swaps) is an
    ///      implementation concern and MUST NOT be inferred by integrators
    ///      from this call alone.
    /// @param newTokens The new ordered set of constituent token addresses.
    /// @param newWeights The new ordered set of target weights in basis points.
    function rebalance(address[] calldata newTokens, uint256[] calldata newWeights)
        external;

    // --- Preview Functions ---

    /// @notice Estimates shares that would be minted for given amounts.
    /// @dev MUST return the same value as `contribute` would return if called
    ///      in the same transaction. MUST NOT revert except for invalid inputs.
    ///      MUST NOT vary by caller. MUST round down.
    ///      MUST use the same valuation function as `contribute`.
    /// @param amounts Ordered array of constituent token amounts.
    /// @return lpAmount Estimated shares that would be minted.
    function previewContribute(uint256[] calldata amounts)
        external view returns (uint256 lpAmount);

    /// @notice Estimates constituent amounts returned for burning shares.
    /// @dev MUST return the same value as `withdraw` would return if called
    ///      in the same transaction. MUST NOT revert except for invalid inputs.
    ///      MUST round down.
    /// @param lpAmount The number of shares to simulate burning.
    /// @return amounts Estimated constituent amounts, ordered by `getConstituents`.
    function previewWithdraw(uint256 lpAmount)
        external view returns (uint256[] memory amounts);
}

The IERC7621 interface identifier is 0xc9c80f73. Implementations MUST return true for this identifier via ERC-165. Implementations MUST also return true for the ERC-173 interface identifier (0x7f5828d0).

Ownership

Basket ownership MUST conform to ERC-173. The address returned by owner() is the only address authorized to call rebalance. Transferring ownership via transferOwnership(address) MUST emit the ERC-173 OwnershipTransferred event. Implementations SHOULD also emit OwnershipTransferred(address(0), initialOwner) at contract creation, per ERC-173 convention.

The standard does not prescribe how ownership is implemented internally. An ERC-721 token backing the owner() return value, a multisig, a governance contract, or a simple storage variable are all valid. Implementations MAY use ERC-721 to represent ownership when transferability of management rights on NFT marketplaces is desired.

Share holders' claims MUST NOT be affected by ownership changes.

If ownership is renounced via transferOwnership(address(0)), rebalance becomes permanently unavailable since no caller can satisfy the owner() check. Implementations that wish to prevent permanent lockout SHOULD restrict or override renunciation behavior.

Weight Encoding

Weights MUST be expressed in basis points (10000 = 100%). The sum of all constituent weights MUST equal 10000. Implementations MUST NOT allow constituents with zero weight; remove them instead.

Weights are informational targets and MUST NOT be assumed to reflect current reserve ratios. Actual reserves MAY diverge from targets between rebalances, during contributions, and during withdrawals. getConstituents() returns target weights; getReserve() returns accounted reserves recognized by the implementation.

Constituent Constraints

Constituent token addresses MUST be unique within a basket; duplicates are not permitted. Constituent addresses MUST NOT be address(0). These constraints MUST be enforced during rebalance and during initialization.

Constituent Ordering

The order of constituent tokens returned by getConstituents() MUST be stable between calls to rebalance. The amounts arrays passed to contribute and returned by withdraw (and their preview counterparts) MUST follow this same ordering. After a rebalance, the ordering is determined by the newTokens array provided.

Valuation

Implementations MUST define a deterministic function mapping current reserves to a total basket value, reported by totalBasketValue(). Shares minted during contribution MUST be proportional to the value contributed relative to this total. The standard does not prescribe the valuation function (summing reserve balances, querying oracles, and using time-weighted prices are all valid approaches), but the function MUST be deterministic and MUST be the same function used by previewContribute.

Contribution Mechanics

Shares minted MUST be monotonically non-decreasing with respect to amounts contributed; contributing more of any constituent MUST NOT result in fewer shares.

When rounding, implementations MUST round shares minted down (favoring the basket over the contributor). This matches ERC-4626's rounding convention.

Withdrawal Mechanics

Constituent amounts returned during withdrawal MUST be proportional to the shares burned relative to total supply. For each constituent:

amount_i = reserve_i * lpAmount / totalSupply

When rounding, implementations MUST round amounts down (favoring the basket over the withdrawer).

If totalSupply() is zero, withdraw MUST revert.

Initialization

When totalSupply() is zero (empty basket), the implementation MUST mint shares according to a deterministic initialization rule. That rule MUST be the same rule used by previewContribute in the same state, and MUST NOT vary by caller. Implementations SHOULD document their initialization rule and SHOULD mitigate first-depositor inflation attacks using dead shares, virtual shares, or an equivalent mechanism.

Edge Cases

Scope and Non-Goals

This standard covers:

This standard does not cover:

Rationale

Relationship to ERC-4626 and ERC-7575

ERC-4626 standardizes single-asset vaults with deposit(assets, receiver) and withdraw(assets, receiver, owner). ERC-7575 extends that model to multi-asset vault entry points while preserving ERC-4626 share semantics. We considered extending either, but manager-rebalanced weighted baskets diverge from both:

A separate standard with a dedicated multi-asset, manager-rebalanced model is more appropriate than overloading existing vault semantics.

That said, we follow ERC-4626's conventions where they apply: the contract is itself the share token (ERC-20), preview functions provide read-only estimates, rounding favors the contract over the user, and totalBasketValue() serves a role analogous to totalAssets().

ERC-7621 is not an alternative implementation of ERC-4626 or ERC-7575. Those standards center vault accounting and asset entry points. ERC-7621 centers managed basket composition as a standardized state surface: constituent membership, target weights, and owner-driven rebalancing are explicit interface elements with no equivalent in either vault standard.

Dimension ERC-4626 ERC-7575 ERC-7621
Asset model Single underlying Multiple entry points, single share Multiple constituents with target weights
Composition state N/A N/A getConstituents(), getWeight(), isConstituent()
Composition mutation N/A N/A rebalance() — owner changes constituents and weights
Deposit Single asset deposit(assets, receiver) Per-asset entry points Multi-asset contribute(amounts[], receiver, minShares)
Withdrawal Single asset withdraw(assets, receiver, owner) Per-asset exit points Proportional multi-asset withdraw(lpAmount, receiver, minAmounts[])
Slippage protection None (added by ERC-5143) None Built-in minShares / minAmounts
Management role Not standardized Not standardized owner() via ERC-173

Ownership via ERC-173

Rather than defining a custom ownership accessor, this standard reuses ERC-173 which already standardizes owner(), transferOwnership(address), and OwnershipTransferred. Registries, wallets, and UIs that recognize ERC-173 will work with basket tokens without custom integration.

The standard does not mandate the internal ownership mechanism. An implementation backed by an ERC-721 token can implement owner() as IERC721(nftContract).ownerOf(tokenId) and transferOwnership as an NFT transfer. A simple Ownable contract works equally well. This flexibility is important because basket governance varies in practice: single EOAs, multisigs, DAOs, and NFT-based ownership are all in production use.

Slippage Protection

The minShares parameter on contribute and minAmounts parameter on withdraw provide on-chain slippage protection. ERC-4626 omitted these, and ERC-5143 was later created specifically to add them. As with ERC-5143's extension of ERC-4626, ERC-7621 includes slippage protection in the base interface because baskets are sensitive to execution drift across multiple assets. Including it avoids the need for a follow-on ERC and makes the interface safer for direct EOA interaction.

Preview Functions

Following ERC-4626's convention, previewContribute and previewWithdraw provide estimates for display and integration logic. They MUST return values matching what the corresponding action function would return if called in the same transaction, but MAY differ from actual results if state changes between the preview call and the action call.

Weights as Targets

Weights represent the basket's intended allocation, not a guarantee about current reserves. After a contribution or withdrawal, actual reserve ratios will differ from target weights. After a rebalance, reserves may or may not be realigned depending on the implementation. This is intentional. Mandating immediate reserve alignment would require the standard to specify swap execution, which varies too widely across implementations (AMM routing, off-chain signatures, aggregator calls) to standardize.

Rebalance Semantics

The standardized effect of rebalance is updating the constituent set and target weights. Any reserve realignment (executing swaps to match new weights) is an implementation concern. Integrators MUST NOT assume that a rebalance call results in reserves matching the new weights; it may only update targets, with reserve alignment deferred to future contributions and withdrawals, or through a separate implementation-specific mechanism. Accordingly, rebalance standardizes changes to intended composition, not execution strategy.

Backwards Compatibility

This standard introduces a new interface and is not backwards compatible with any existing ERC. It extends ERC-20 without modifying it and reuses ERC-173 for ownership. Basket tokens are standard ERC-20 tokens and work with all existing ERC-20 infrastructure.

Test Cases

Test cases are provided in the assets directory. Key scenarios covered:

Reference Implementation

A minimal reference implementation is provided in the assets directory. It includes:

The reference implementation is intentionally minimal. It does not include swap routing, fee collection, oracle integration, or advanced inflation-attack mitigations. These are production concerns, not interface concerns.

Security Considerations

Reentrancy

contribute, withdraw, and rebalance make external calls to ERC-20 token contracts. Implementations MUST use reentrancy guards or follow checks-effects-interactions. The withdraw function is especially sensitive; shares MUST be burned before constituent tokens are transferred out.

First-Contributor Manipulation

The first contributor to an empty basket controls the initial share-to-reserve exchange rate and can manipulate it to extract value from subsequent contributors. This is the same inflation attack described in ERC-4626. Implementations SHOULD mint a minimum amount of shares to a dead address on first contribution, or use virtual shares.

Rebalancing Front-Running

Rebalancing transactions are visible in the mempool and reveal the intended weight changes. If the implementation executes swaps during rebalance, those trades will be sandwiched. Implementations that execute swaps during rebalance SHOULD use private mempools, commit-reveal schemes, or off-chain routing with signature verification. Enforcing slippage limits on rebalancing swaps is also recommended.

Owner Trust

Share holders trust the basket owner to rebalance responsibly. The owner can change constituents to illiquid or worthless tokens, or trigger rebalancing at unfavorable prices. This trust assumption is inherent to the model. Implementations MAY add timelocks, governance voting, or maximum-slippage constraints to reduce this trust assumption, but these are not required by the standard.

Donation Attacks

Tokens sent directly to the basket contract (outside of contribute) can skew the relationship between tracked reserves and actual balances. Implementations SHOULD track reserves via internal accounting rather than relying on balanceOf(address(this)), and SHOULD ignore tokens received outside the standard contribution flow.

Fee-on-Transfer and Rebasing Tokens

Baskets that track reserves via internal accounting will desync if a constituent charges transfer fees or rebases. Implementations that allow arbitrary constituents SHOULD check actual received balances after each transfer rather than trusting the transfer amount.

Poisoned Constituents

A constituent token with blacklist, pause, or other transfer-restriction functionality can freeze basket withdrawals entirely. If any single constituent cannot be transferred out, withdraw reverts for all holders. Implementations SHOULD consider maintaining an allowlist of acceptable constituent tokens, or implement a partial-withdrawal mechanism that skips non-transferable constituents.

Preview Function Safety

previewContribute and previewWithdraw are manipulable by altering on-chain state (e.g., donating tokens to the basket). They SHOULD NOT be used as price oracles. Integrators that need manipulation-resistant pricing SHOULD use external oracles or time-weighted calculations.

Copyright

Copyright and related rights waived via CC0.