A Dutch-auction settlement module for vaults, index products, and treasury managers that need to exchange portfolio assets transparently on-chain.
Sellers escrow an exact amount of one ERC-20, define a descending normalized price curve, and let solvers fill any remaining portion atomically. An optional oracle floor prevents fills outside a configured market-price deviation.
Educational portfolio implementation. The contracts have not been independently audited and must not be used to secure production funds.
flowchart LR
A["Seller escrows sell token"] --> B["Dutch auction starts"]
B --> C["Solvers fill portions"]
C --> D{"Inventory empty?"}
D -->|"Yes"| E["Closed"]
D -->|"No, auction expires"| F["Return remainder"]
A -->|"No fills"| G["Seller cancellation"]
Each fill transfers the buy token and protocol fee into the auction house, then atomically pays the seller recipient and releases sell inventory to the solver.
- Linear Dutch price decay.
- Partial fills and buyer-side maximum-cost protection.
- Normalized prices across ERC-20 tokens with up to 18 decimals.
- Optional fresh-oracle deviation floor.
- Protocol fee snapshot per auction.
- Permissionless expiry closure and seller-only pre-fill cancellation.
- Pause blocks new risk while cancellation and expiry closure remain available.
- Exact escrow accounting by sell token.
For each auction:
totalSell == soldAmount + remainingSell + returnedAmount
Across auctions sharing a token:
sellToken.balanceOf(auctionHouse) == escrowedSellBalance[sellToken]
The invariant test suite continuously checks both properties across randomized partial fills.
See SPEC.md and THREAT_MODEL.md.
forge install --no-git --shallow \
OpenZeppelin/openzeppelin-contracts@v5.6.1 \
foundry-rs/forge-std@v1.16.2
forge fmt --check
forge test -vvv
forge lintPinned toolchain:
- Foundry
v1.7.1 - Solidity
0.8.34 - OpenZeppelin Contracts
v5.6.1 - forge-std
v1.16.2
Prices use 1e18 precision and represent whole buy tokens per whole sell token. The contract applies token decimals when calculating base-unit payment:
buyAmount =
sellBaseUnits
× priceX18
× 10^buyDecimals
÷ 10^sellDecimals
÷ 1e18
Payment is rounded up in favor of the seller.
forge script script/Deploy.s.sol:Deploy --rpc-url "$RPC_URL" --broadcastConfigure OWNER, FEE_RECIPIENT, and PROTOCOL_FEE_BPS from .env.example.
DeployDemo.s.sol atomically deploys two test tokens, a price oracle, and the auction house, then creates and fully
fills an oracle-bounded Dutch auction:
export DEMO_DEPLOYER=0xYourDeployer
forge script script/DeployDemo.s.sol:DeployDemo \
--rpc-url https://sepolia.base.org \
--account portfolio-testnet \
--broadcastThe atomic scenario prevents block-time drift between auction creation and its immediate smoke-test fill.
See the live Base Sepolia deployment record for verified addresses, the creation transaction, and final auction accounting.
- Only standard, non-rebasing, non-fee-on-transfer ERC-20s are supported.
- The external oracle is trusted to publish the correct normalized pair price.
- The contract does not source liquidity or guarantee auction completion.
- Updating the fee recipient affects where fees are paid; each auction snapshots only its fee rate.
- This implementation is intentionally non-upgradeable.
MIT