Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ERC-7621 reference implementation

A DEX- and oracle-agnostic reference implementation of ERC-7621 (Basket Token, Draft): a basket of ERC-20 constituents held at target weights, where the basket contract is itself the ERC-20 share token and holders have a proportional claim on the accounted reserves. Everything venue-specific or price-feed-specific lives behind narrow interfaces; the core basket contract, src/BasketToken.sol, imports nothing but OpenZeppelin and its own interfaces — no DEX, no router, no particular oracle.

Abstractions

The design separates the core from two concerns a basket should never hard-code: how a constituent is priced, and how it is traded.

Valuation — IBasketOracle

Every price feed, TWAP, NAV attestation, or constant-price rule reaches a basket through this interface. A basket never learns what is behind it.

interface IBasketOracle {
    function unitOfAccount() external view returns (address);
    function unitDecimals() external view returns (uint8);
    function validateAsset(address token) external view;
    function quote(address token, uint256 amount) external view returns (uint256 value);
    function quoteInverse(address token, uint256 value) external view returns (uint256 amount);
}

Implementations must round quote/quoteInverse down, return zero for a zero amount, revert when a price is unusable (stale, non-positive, paused, or unregistered), stay monotonically non-decreasing in amount, never depend on msg.sender, and make validateAsset(token) revert if and only if the module cannot price token. See src/interfaces/IBasketOracle.sol for the full obligations and tests/mocks/MockBasketOracle.t.sol for the test double that pins them.

Execution — IBasketExecution

Every venue — AMM, aggregator, RFQ, OTC desk — reaches a basket through this interface. Routing, hop count, and slippage strategy are entirely the module's business.

interface IBasketExecution {
    function supportsPair(address tokenIn, address tokenOut) external view returns (bool);
    function swap(
        address tokenIn,
        address tokenOut,
        uint256 amountIn,
        uint256 minAmountOut
    )
        external
        returns (uint256 amountOut);
}

Implementations must pull exactly amountIn of tokenIn from msg.sender, deliver at least minAmountOut of tokenOut to msg.sender before returning, revert if either is impossible, and leave no residual allowance or retained funds. RebalancingBasketToken does not take a module's word for this: it records its own balances before and after every swap call and reverts the whole trade if either delta is wrong, so a misbehaving module fails safely instead of corrupting reserves. See src/interfaces/IBasketExecution.sol for the full obligations.

Conformance

This implementation deviates from ERC-7621 in exactly one place: it permits a constituent to carry a zero weight instead of requiring it be removed, because the standard's own removal instruction has no safe answer for what happens to that constituent's reserve. Every other normative requirement is implemented and asserted by tests/standard/ERC7621Conformance.t.sol, where every test maps to a normative requirement and test_deviation_acceptsZeroWeightConstituent records the deviation and its reasoning.

Retiring a constituent

Removal requires an empty position, and a zero weight is how the basket says a position should become empty (the deviation above). Retirement is therefore four calls, and the owner appears in only the first and last:

  1. rebalance with the departing asset at weight 0 and the remaining 10,000 basis points distributed across the others.
  2. Permissionless rebalanceTrade sells the bulk of the now-surplus reserve down under the basket's existing drift band, trade caps, and oracle price floor.
  3. Once the remainder falls inside the drift band and rebalanceTrade can no longer size a trade against it, permissionless retireConstituent clears that sub-band residue — the one call that waives the band and the minimum-trade floor, because clearing what those guards leave behind is its entire reason to exist.
  4. rebalance again, this time without the token, now that its reserve is zero.

Steps 2 and 3 are open to anyone because the zero weight is the owner's public, on-chain declaration that the asset is leaving: after it, a caller chooses nothing that matters — not whether to sell (the target is zero, so the whole reserve is surplus), not how much (computed and capped), not at what price (the oracle floor), and not through what venue (the allowlist). See tests/rebalancing/Retirement.t.sol for the end-to-end test.

Quickstart

bun install
forge build
forge test

Repository layout

src/
  interfaces/
    IERC7621.sol                  the standard, verbatim, CC0
    IBasketOracle.sol             valuation abstraction
    IBasketExecution.sol          execution abstraction
    IExecutionRegistry.sol
  types/BasketTypes.sol           RiskConfig, TradePreview
  BasketToken.sol                 ERC-20 + ERC-165 + ERC-173 + ERC-7621
  RebalancingBasketToken.sol      + oracle-bounded trading, drift bands, keeper reward
  ExecutionRegistry.sol
  BasketFactory.sol
  RebalancingBasketFactory.sol
  oracles/
    ChainlinkBasketOracle.sol     example: AggregatorV3 feeds
    FixedPriceOracle.sol          example: owner-set prices
  execution/
    UniswapV2StyleExecution.sol   example: V2-shaped router, locally declared interface
tests/
  standard/ basket/ rebalancing/ registry/ factory/ oracles/ execution/ fuzz/ invariants/ mocks/

src/oracles/ and src/execution/ are examples, not part of the standard's surface: nothing under src/*.sol, src/interfaces/, or src/types/ imports either directory. A basket only ever sees a module through IBasketOracle or IBasketExecution. Writing your own module in either directory's place — a different price feed shape, a different venue — is the expected way to use this repository; start with the obligations documented on IBasketOracle and IBasketExecution.

BasketFactory deploys BasketToken; RebalancingBasketFactory deploys RebalancingBasketToken against the same BasketFactory.BasketParams shape. They are two contracts rather than one because a single factory embedding both baskets' creation bytecode exceeds the EIP-170 contract size limit.

One consequence is worth knowing before you choose a deployment path. Because a factory embeds the basket's creation code, the basket compiles under the factory's compiler settings, and foundry.toml gives the factories 200 optimizer runs so that RebalancingBasketFactory keeps real headroom under the size limit rather than the 22 bytes it had at the project-wide 10,000 runs. So a basket deployed by a factory carries 200-run bytecode, while a basket deployed directly carries 10,000-run bytecode: identical source and identical behavior, but the factory-deployed one costs a little more gas per call and a little less to deploy. Deploy directly when per-call gas matters; use a factory when you want the registry, the BasketCreated event, and one-transaction setup.

Security considerations

Read this section before deploying anything derived from this code. tests/audit/AuditPoC.t.sol holds executable proofs for the items below; run forge test --match-path tests/audit/AuditPoC.t.sol to see them.

A contribution and a withdrawal in one transaction is a fee-free swap at the oracle's price. This is a property of value-based contribution, not a bug, and it is the main thing to understand before choosing an oracle. contribute mints shares for the value supplied and withdraw returns a proportional slice of every constituent, so contributing one asset and immediately redeeming trades that asset for the basket mix at exactly the oracle's quote, with no fee and no permission. Whenever the oracle's price differs from the market's — a Chainlink feed inside its deviation threshold, a heartbeat window, any lagging source — the difference is extractable from existing holders, repeatedly, bounded only by basket size. test_pocA_contributeWithdrawIsAFreeOracleSwap shows a 2% gap being harvested.

The standard puts fee models out of scope, so this reference implementation charges nothing and does not pretend the exposure away. A production basket needs at least one of: an entry or exit fee that exceeds the oracle's worst-case error; contributions restricted to proportional in-kind baskets rather than arbitrary value; a restriction on contributing and withdrawing from one address in one block; or an oracle whose error is smaller than the friction an arbitrageur pays elsewhere.

The execution-adapter allowlist is mandatory, deliberately. rebalanceTrade is permissionless and lets the caller name the venue, so without an allowlist the caller both chooses the counterparty and pockets the spread — which means always choosing the worst execution the slippage bound permits. A module honouring minAmountOut exactly passes every balance-delta check while keeping maxSlippageBps of each trade. RebalancingBasketToken therefore refuses a zero registry at construction. Allowlist only modules you have read.

Owner trust, and what the oracle timelock does not cover. proposeOracle / commitOracle put ORACLE_TIMELOCK between announcing a valuation change and it taking effect, so repointing valuation becomes a visible intention holders can exit ahead of rather than a single transaction that drains the basket. It does not constrain an oracle whose prices the owner can already change from behind: FixedPriceOracle is exactly such a module, and pointing a basket at one grants its owner the same power the timelock was added to remove. The owner also chooses constituents and weights, which the standard treats as inherent trust. Timelock the owner itself, or use an oracle nobody in the system controls.

Inherited from the design. Reserves are tracked internally, so tokens sent directly to a basket are never credited and never swept — donations cannot move totalBasketValue, and they cannot be recovered either. Every transfer is checked for an exact balance delta, so fee-on-transfer and rebasing constituents are rejected rather than mis-accounted. The first contribution locks MINIMUM_SHARES at the dead address against first-depositor inflation. A constituent that can block transfers can block every withdrawal, since withdraw pays out the whole set.

License

CC0-1.0. Every file in this repository carries the CC0-1.0 SPDX header so the work can be offered upstream to ethereum/ERCs.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages