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.
The design separates the core from two concerns a basket should never hard-code: how a constituent is priced, and how it is traded.
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.
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.
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.
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:
rebalancewith the departing asset at weight 0 and the remaining 10,000 basis points distributed across the others.- Permissionless
rebalanceTradesells the bulk of the now-surplus reserve down under the basket's existing drift band, trade caps, and oracle price floor. - Once the remainder falls inside the drift band and
rebalanceTradecan no longer size a trade against it, permissionlessretireConstituentclears 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. rebalanceagain, 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.
bun install
forge build
forge testsrc/
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.
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.
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.