support.eth curator

Requirements

EARS requirements specification for Curator Studio

Requirements

Structured requirements using EARS notation (Easy Approach to Requirements Syntax). For conceptual background, see Concepts. For system design, see Architecture.

Templates used:

  • Ubiquitous: The [system] shall [behavior].
  • Event-driven: When [trigger], the [system] shall [behavior].
  • State-driven: While [state], the [system] shall [behavior].
  • Unwanted behavior: If [condition], then the [system] shall [behavior].

Strategy Contract

REQ-S1: The Strategy shall store: an array of allocations (recipient address, uint96 weight, string label), a totalWeight, an owner, a sourceStrategy address, and a metadataURI string.

REQ-S2: The Strategy shall hold immutable references to the SplitsWarehouse and ReverseRegistrar contracts, shared across all clones.

REQ-S3: The Strategy shall enforce a maximum of 50 allocations, no zero-address recipients, no zero weights, and no weight overflow.

REQ-S4: When initialize(config, ensName) is called, the Strategy shall set the owner, sourceStrategy, metadataURI, and allocations from config, read rebalanceDistributeCooldown() from the deploying factory and store it on the clone, and if ensName is non-empty, call reverseRegistrar.setName(ensName).

REQ-S5: When native ETH is received via receive(), the Strategy shall emit Funded(NATIVE_TOKEN, msg.sender, msg.value).

REQ-S6: When distribute(token) is called, the Strategy shall calculate each recipient's share using Math.mulDiv(balance, weight, totalWeight), assign the remainder to the last recipient, skip zero-amount recipients in the warehouse batch, and call warehouse.batchDeposit with the remaining recipients and amounts in a single transaction. For recipients registered with the strategy's own factory (isFactoryStrategy(recipient) == true), the Strategy shall transfer the allocation to the child strategy on-chain (native ETH via call, ERC-20 via safeTransfer) and shall not call distribute on the child; a separate transaction may call the child's permissionless distribute(token) to route those funds onward.

REQ-S7: When distribute(token) is called, the Strategy shall emit Distributed(token, totalAmount, allocations) and Payout(recipient, token, amount) for each non-zero payout.

REQ-S8: If totalWeight is 0 when distribute is called, then the Strategy shall revert with NoAllocationsSet.

REQ-S9: If the token balance is 0 when distribute is called, then the Strategy shall revert with NoBalanceToDistribute.

REQ-S10: When rebalance(allocations, metadataURI) is called by the owner, the Strategy shall replace all allocations, update totalWeight, update metadataURI, set distributeUnlockAt = block.timestamp + rebalanceDistributeCooldown, and emit Rebalanced(allocations, metadataURI).

REQ-S11: If rebalance is called by a non-owner, then the Strategy shall revert.

REQ-S12: The Strategy shall implement ERC-1155 receiver interfaces (onERC1155Received, onERC1155BatchReceived, supportsInterface) to temporarily hold wrapped ENS names.

REQ-S13: The Strategy shall use ReentrancyGuard on distribute and SafeERC20 for all token transfers.

REQ-S14: If _setAllocations is called with an empty array, then the Strategy shall revert with EmptyAllocations. If any allocation recipient equals the Strategy's own address, then the Strategy shall revert with SelfAllocation.

REQ-S15: While block.timestamp < distributeUnlockAt, the Strategy shall revert calls to distribute(token) and distribute(token, amount) with DistributeLocked(distributeUnlockAt).

REQ-S16: The Strategy shall expose a distribute(address token, uint256 amount) overload that distributes exactly amount of token from the contract's balance. If amount exceeds the current balance, it shall revert with AmountExceedsBalance.

REQ-S17: The Strategy shall inherit PausableUpgradeable and expose pause() and unpause() gated by onlyOwner. While paused, rebalance, distribute(token), and distribute(token, amount) shall revert with EnforcedPause.


StrategyFactory Contract

REQ-F1: The StrategyFactory constructor shall accept (address warehouse, address reverseRegistrar, uint256 rebalanceDistributeCooldown), deploy the Strategy implementation, and store all four as immutables. If warehouse == address(0), it shall revert with ZeroWarehouse.

REQ-F2: When create(config, label) is called, the StrategyFactory shall deploy a minimal proxy clone of the implementation, set isFactoryStrategy[strategy] = true, and initialize it with the provided config.

REQ-F3: When create is called with a non-empty label and ENS is configured, the StrategyFactory shall register the subdomain via ForeverSubnameRegistrar, initialize the strategy with label.ensDomain as the ENS name, set forward resolution via PublicResolver, and transfer the ENS name to the strategy via NameWrapper.

REQ-F4: When create is called with an empty label, the StrategyFactory shall initialize the strategy with an empty ENS name.

REQ-F5: When create completes, the StrategyFactory shall emit StrategyCreated(strategy, config, label, node).

REQ-F6: When setENSName(strategy, label) is called, the StrategyFactory shall register or connect the subdomain, set forward and reverse resolution, transfer name ownership to the strategy, and emit ENSNameSet(strategy, label, node).

REQ-F7: When configureENS is called, the StrategyFactory shall store the ENS registrar, resolver, nameWrapper, parentNode, and ensDomain. If already configured, it shall revert.

REQ-F8: When createDeterministic(config, salt) is called, the StrategyFactory shall derive finalSalt = keccak256(abi.encode(msg.sender, salt)), deploy using Clones.cloneDeterministic(finalSalt), set isFactoryStrategy[strategy] = true, and initialize. No ENS support.

REQ-F9: The StrategyFactory shall implement ERC-1155 receiver interfaces to temporarily hold wrapped ENS names during the create flow.

REQ-F10: predictDeterministicAddress(address deployer, bytes32 salt) shall return Clones.predictDeterministicAddress(implementation, keccak256(abi.encode(deployer, salt))), matching the createDeterministic path for the same (deployer, salt).

REQ-F11: The StrategyFactory shall maintain mapping(address => bool) public isFactoryStrategy and set it to true in every strategy-deployment path. setENSName(strategy, label) shall revert with UnknownStrategy for addresses not in this mapping.

REQ-F12: The StrategyFactory shall expose assignPendingLabel(bytes32 node, address claimant) onlyOwner which records pendingLabelClaim[node] = claimant after validating isFactoryStrategy[claimant]. When setENSName is called for a name currently owned by the factory, the strategy being configured shall match pendingLabelClaim[node], and the entry shall be cleared on consumption. If no mapping exists, it shall revert with LabelNotAssigned.

REQ-F13: The StrategyFactory shall expose a view rebalanceDistributeCooldown() returns (uint256) returning the immutable cooldown, used by newly-initialized Strategy clones.


YieldRedirector4626 Contract

REQ-Y1: The YieldRedirector4626 shall implement ERC-4626 with totalAssets() returning principal in asset units (live-value accounting), keeping the wrapper's share price 1:1 with the deposited asset.

REQ-Y2: When initialize(sourceVault, yieldRecipient, initialOwner, minHarvestAmount, minHarvestInterval) is called, the YieldRedirector4626 shall validate all addresses are non-zero, require yieldRecipient.code.length > 0 (revert RecipientNotContract otherwise), store sourceVault and yieldRecipient, store minHarvestAmount and minHarvestInterval, init ERC-4626 with the source vault's asset, set name to "YieldRedirector " + sourceVault.name() and symbol to "yR-" + sourceVault.symbol(), initialize Pausable, and approve the source vault for unlimited spending.

REQ-Y3: When _deposit is called, the YieldRedirector4626 shall pull assets from the caller, deposit into sourceVault, revert with DepositReturnedNoShares if the source vault minted zero shares (e.g. 100% deposit fee), increment principal by the deposited asset amount, and mint wrapper shares to the receiver.

REQ-Y4: When _withdraw is called, the YieldRedirector4626 shall clamp the requested assets to min(principal, maxWithdraw_fromVault), burn the corresponding wrapper shares, call sourceVault.withdraw(toWithdraw, receiver, this), and decrement principal by the delivered asset amount.

REQ-Y5: If a withdrawal clamp reduces the deliverable amount to below the caller's requested assets, the wrapper shall still burn only the shares corresponding to the delivered amount; maxWithdraw / maxRedeem shall advertise the effective clamp upstream.

REQ-Y6: The YieldRedirector4626 shall expose harvestable() returning max(sourceVault.convertToAssets(sourceVault.balanceOf(this)) - principal, 0). surplus() is preserved as a backward-compatible alias that returns the same value.

REQ-Y7: When harvest() is called, the YieldRedirector4626 shall compute amount = harvestable(), withdraw amount from the source vault to yieldRecipient, call distribute(asset, amount) on the recipient inside a try/catch block (emitting DistributeFailed(recipient, reason) on revert), set lastHarvestAt = block.timestamp, and emit Harvest(amount, yieldRecipient).

REQ-Y8: If block.timestamp < lastHarvestAt + minHarvestInterval, harvest shall revert with HarvestTooSoon(readyAt). If harvestable() < minHarvestAmount, it shall revert with HarvestBelowMinimum(available, minimum).

REQ-Y9: When setYieldRecipient(newRecipient) is called by the owner, the YieldRedirector4626 shall update yieldRecipient and emit YieldRecipientUpdated(old, new). If newRecipient is zero or an EOA, it shall revert with RecipientNotContract.

REQ-Y10: The YieldRedirector4626 shall use ReentrancyGuard on deposit, withdraw, and harvest.

REQ-Y11: The YieldRedirector4626 shall override maxDeposit, maxMint, maxWithdraw, and maxRedeem to clamp to the source vault's corresponding limits and — for withdrawals — the currently tracked principal.

REQ-Y12: The YieldRedirector4626 shall expose sweep(address token, address to) gated by onlyOwner that transfers the full balance of token to to and emits Swept(token, to, amount). If token == address(sourceVault), it shall revert with CannotSweepSourceShares.

REQ-Y13: The YieldRedirector4626 shall inherit PausableUpgradeable and expose pause() / unpause() gated by onlyOwner. While paused, deposit, mint, and harvest shall revert with EnforcedPause; withdraw and redeem shall remain callable so depositors can always exit.


YieldRedirectorFactory Contract

REQ-YF1: The YieldRedirectorFactory constructor shall accept (uint256 minHarvestAmount, uint256 minHarvestInterval), deploy the YieldRedirector4626 implementation, and store all three values as immutables.

REQ-YF2: When create(sourceVault, yieldRecipient, owner) is called, the YieldRedirectorFactory shall deploy a minimal proxy clone, set isFactoryRedirector[redirector] = true, initialize it with the stored minHarvestAmount and minHarvestInterval, and emit YieldRedirectorCreated(redirector, sourceVault, yieldRecipient, owner).

REQ-YF3: When createDeterministic(sourceVault, yieldRecipient, owner, salt) is called, the YieldRedirectorFactory shall derive finalSalt = keccak256(abi.encode(msg.sender, salt)), deploy using Clones.cloneDeterministic(finalSalt), set isFactoryRedirector[redirector] = true, and initialize.

REQ-YF4: predictDeterministicAddress(address deployer, bytes32 salt) shall return Clones.predictDeterministicAddress(implementation, keccak256(abi.encode(deployer, salt))).


SplitsWarehouse (External)

REQ-W1: The Strategy shall call warehouse.batchDeposit(receivers, token, amounts) during distribution — sending ETH as msg.value for native token, or using forceApprove + batchDeposit for ERC-20.

REQ-W2: Recipients shall withdraw via warehouse.withdraw(owner, token). Token IDs follow ERC-6909: uint256(uint160(tokenAddress)).


Indexer

REQ-I1: When StrategyCreated is emitted, the Indexer shall fetch metadata from metadataURI, calculate feeBps from the first allocation as (weight × 10000) / totalWeight, store the strategy record with all metadata, store allocation records (version 1), and if sourceStrategy is non-zero, store a fork record and increment timesForked on the source.

REQ-I2: When Rebalanced is emitted, the Indexer shall increment allocationsVersion, update metadata and metadataURI, and store new allocation records with the new version number.

REQ-I3: When Distributed is emitted, the Indexer shall store a distribution record with USD-normalized amount, and decrement the strategy balance.

REQ-I4: When Payout is emitted, the Indexer shall store a payout record and increment the recipient's warehouse balance and totalEarned.

REQ-I5: When a Transfer event matches an incoming transfer to a known strategy, the Indexer shall store a transfer record (direction: "in"), increment strategy balance and totalReceived, and track the donor (insert if new, increment strategy uniqueDonors; update lastDonationAt and totalDonations if existing).

REQ-I6: When a Transfer event matches an outgoing transfer from a known strategy, the Indexer shall store a transfer record (direction: "out").

REQ-I7: When a Transfer event matches a withdrawal from SplitsWarehouse, the Indexer shall decrement the user's warehouse balance and increment totalClaimed.

REQ-I8: When YieldRedirectorCreated is emitted, the Indexer shall read asset, name, and symbol from the redirector contract and store the yield_redirector record.

REQ-I9: When Harvest is emitted, the Indexer shall store a harvest record with USD-normalized amount, and increment totalHarvested, harvestCount, and set lastHarvestAt on the redirector.

REQ-I10: When Deposit or Withdraw is emitted on a YieldRedirector, the Indexer shall increment or decrement principal on the redirector record.

REQ-I11: The Indexer shall expose GET /api/strategies/trending (query params: period, limit), GET /api/stats (protocol-wide aggregates), and GET /api/strategies/:address/lineage (fork tree).

REQ-I12: The Indexer shall use Ponder's factory pattern to automatically discover and index new Strategy and YieldRedirector contracts as they are deployed.


SDK

REQ-SDK1: The CuratorSDK shall auto-configure a PublicClient and Indexer from the wallet's chain ID, falling back to chain 31337 if no wallet is provided.

REQ-SDK2: The SDK shall provide strategy.create(config), strategy.getData(address), strategy.balanceOf(address, token), strategy.rebalance(address, allocations, metadataURI), strategy.distribute(address, token), and strategy.setENSName(address, label).

REQ-SDK3: The SDK shall provide warehouse.withdraw(owner, token) and warehouse.balanceOf(owner, token).

REQ-SDK4: The SDK shall provide yieldRedirector.create(sourceVault, yieldRecipient, owner), yieldRedirector.harvest(address), yieldRedirector.surplus(address) (the on-chain alias of harvestable()), yieldRedirector.principal(address), yieldRedirector.sourceVaultValue(address), and yieldRedirector.setYieldRecipient(address, newRecipient).

REQ-SDK5: The SDK shall provide ens.available(label), ens.register(label, owner?), ens.getAddress(name), ens.setAddress(name, address), ens.setReverseRecord(name), ens.setReverseNameForAddr(addr, name), and ens.registerWithAddress(label, resolveToAddress, owner?).

REQ-SDK6: The SDK shall provide a CuratorProvider React context that creates a CuratorSDK instance from the connected wagmi wallet and provides it via useCuratorSDK().

REQ-SDK7: The SDK shall provide React query hooks for all indexer entities: useStrategies, useStrategyById, useDistributions, usePayouts, useDonors, useStrategyBalances, useForks, useWarehouseBalances, useWarehouseBalance, useYieldRedirectors, useYieldRedirectorById, useHarvests, useTrendingStrategies, useProtocolStats, useStrategyLineage. It shall also provide on-chain read hooks: useStrategyData, useStrategyBalance, useENSGetAddress, useENSAvailable.

REQ-SDK8: The SDK shall provide React mutation hooks: useCreateStrategy, useRebalanceStrategy, useDistributeStrategy, useSetENSName, useWithdrawFromWarehouse, useCreateYieldRedirector, useHarvestYield.

REQ-SDK9: When a mutation hook succeeds, the SDK shall invalidate related query keys after a 3-second delay to allow indexer processing.

REQ-SDK10: The SDK shall provide useInvalidate() for cache invalidation of arbitrary query keys, and useInvalidateENS() which invalidates all ENS-related queries (both custom and wagmi's internal ENS queries) after a 1-second delay.


Data Model

REQ-DM1: The Indexer shall maintain the following entities: strategy, allocation (versioned), distribution, payout, transfer, donor, strategy_balance (per token), fork, warehouse_balance (per user per token), yield_redirector, harvest.

REQ-DM2: The strategy entity shall include: id, owner, sourceStrategy, metadataURI, metadata (JSON), feeRecipient, feeBps, ensLabel, allocationsVersion, timesForked, uniqueDonors, and timestamps.

REQ-DM3: The allocation entity shall include: id, strategyId, recipient, weight, label, version, and createdAt. Current allocations are those where version == strategy.allocationsVersion.

REQ-DM4: The yield_redirector entity shall include: id, owner, sourceVault, yieldRecipient, asset, name, symbol, principal, totalHarvested, totalHarvestedUSD, harvestCount, lastHarvestAt, and timestamps.

REQ-DM5: All USD amounts shall use 18-decimal fixed-point arithmetic: amountUSD = (amount × tokenPriceScaled) / (10 ^ decimals).

On this page