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
Basket: A collection of ERC-20 tokens held at specified target weights, managed as a single unit.
Constituent: An ERC-20 token held within a basket.
Weight: A constituent’s target allocation in basis points (10000 = 100%). Weights are targets; actual reserves MAY diverge from weights at any time.
Reserve: The quantity of a constituent recognized by the basket for accounting purposes. This is the value returned by getReserve() and MAY differ from the raw balanceOf(address(this)) if the implementation uses internal accounting.
Owner: The address returned by ERC-173’s owner(), with management authority over the basket.
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
pragmasolidity^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.
interfaceIERC7621{// --- Errors ---
/// @dev Array lengths do not match.
errorLengthMismatch(uint256expected,uint256actual);/// @dev Weights do not sum to 10000.
errorInvalidWeights(uint256weightSum);/// @dev Amount is zero where a non-zero value is required.
errorZeroAmount();/// @dev Token is not a constituent of the basket.
errorNotConstituent(addresstoken);/// @dev Slippage tolerance exceeded on share minting.
errorInsufficientShares(uint256minimum,uint256actual);/// @dev Slippage tolerance exceeded on constituent withdrawal.
errorInsufficientAmount(uint256index,uint256minimum,uint256actual);/// @dev Duplicate constituent token address.
errorDuplicateConstituent(addresstoken);/// @dev Constituent address is the zero address.
errorZeroAddress();// --- 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.
eventContributed(addressindexedcaller,addressindexedreceiver,uint256lpAmount,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.
eventWithdrawn(addressindexedcaller,addressindexedreceiver,uint256lpAmount,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.
eventRebalanced(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.
functiongetConstituents()externalviewreturns(address[]memorytokens,uint256[]memoryweights);/// @notice Returns the number of constituents.
/// @return count The number of constituent tokens.
functiontotalConstituents()externalviewreturns(uint256count);/// @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`.
functiongetReserve(addresstoken)externalviewreturns(uint256balance);/// @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.
functiongetWeight(addresstoken)externalviewreturns(uint256weight);/// @notice Returns whether an address is a current constituent.
/// @param token The token address to check.
/// @return True if `token` is a constituent.
functionisConstituent(addresstoken)externalviewreturns(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.
functiontotalBasketValue()externalviewreturns(uint256value);// --- 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.
functioncontribute(uint256[]calldataamounts,addressreceiver,uint256minShares)externalreturns(uint256lpAmount);/// @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`.
functionwithdraw(uint256lpAmount,addressreceiver,uint256[]calldataminAmounts)externalreturns(uint256[]memoryamounts);/// @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.
functionrebalance(address[]calldatanewTokens,uint256[]calldatanewWeights)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.
functionpreviewContribute(uint256[]calldataamounts)externalviewreturns(uint256lpAmount);/// @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`.
functionpreviewWithdraw(uint256lpAmount)externalviewreturns(uint256[]memoryamounts);}
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-173OwnershipTransferred 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
contribute with all-zero amounts MUST revert with ZeroAmount.
withdraw with zero lpAmount MUST revert with ZeroAmount.
previewContribute and previewWithdraw with zero inputs MUST return zero, not revert.
getReserve for a non-constituent token MUST return zero.
getWeight for a non-constituent token MUST revert with NotConstituent.
Scope and Non-Goals
This standard covers:
An ERC-20 share token representing proportional claims on a managed basket of ERC-20 constituents.
First-class target weights as standardized, queryable state.
Owner-managed constituent set and weight changes via rebalance.
Multi-asset contribution, proportional withdrawal, and slippage protection.
Preview functions for user interfaces and integrators.
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:
Contributions involve multiple tokens at specified ratios, not a single underlying asset or a set of independent entry points.
Withdrawals return multiple tokens proportionally, not a single asset.
Rebalancing (changing the constituent set and weights over time) has no analogue in ERC-4626 or ERC-7575.
Weights as explicit interface elements (target allocations that an owner actively manages) are not part of either standard.
The convertToShares / convertToAssets model assumes a single exchange rate. Baskets have N exchange rates, one per constituent.
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.
ERC-4626: Single underlying asset. Deposit and withdrawal operate on that single asset. No composition state, no composition mutation, no management role. Slippage protection was not included (later addressed by ERC-5143).
ERC-7575: Multiple entry points with a single share token. Per-asset deposit and withdrawal. No composition state, no composition mutation, no management role. No built-in slippage protection.
ERC-7621: Multiple constituents with target weights. Composition state is queryable via getConstituents(), getWeight(), isConstituent(). Composition mutation via rebalance(), restricted to owner() per ERC-173. Multi-asset contribute(amounts[], receiver, minShares) and proportional multi-asset withdraw(lpAmount, receiver, minAmounts[]). Built-in slippage protection via minShares and minAmounts.
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:
Contribution mints shares proportionally and emits Contributed with caller, receiver, amounts
Contribution reverts with InsufficientShares when shares minted is below minShares
Withdrawal burns shares, returns proportional constituents, and emits Withdrawn with caller, receiver
Withdrawal reverts with InsufficientAmount when any returned amount is below minAmounts
Withdrawal reverts with LengthMismatch when minAmounts length mismatches constituents
Rebalance with duplicate constituent addresses reverts with DuplicateConstituent
Rebalance with zero-address constituent reverts with ZeroAddress
Withdrawal with rounding returns amounts that round down
Rebalance by owner() updates constituents and emits Rebalanced
Rebalance by non-owner reverts
Rebalance with invalid weight sum reverts with InvalidWeights
Contribution with mismatched array length reverts with LengthMismatch
Zero-amount contribution reverts with ZeroAmount
Zero-amount withdrawal reverts with ZeroAmount
Preview functions return values consistent with action functions
getReserve returns zero for non-constituent tokens
getWeight reverts with NotConstituent for non-constituent tokens
getConstituents ordering is stable across calls
totalBasketValue is consistent with previewContribute
owner() and transferOwnership() conform to ERC-173
First contribution to empty basket mints shares deterministically
Ownership renunciation makes rebalance permanently unavailable
Contract creation emits OwnershipTransferred(address(0), initialOwner) per ERC-173 convention
Reference Implementation
A minimal reference implementation is provided in the assets directory. It includes:
BasketToken.sol — a single-basket implementation using direct ERC-20 token deposits
BasketFactory.sol — a factory for deploying baskets
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.