diff --git a/integrations/contracts/Accountant.md b/integrations/contracts/Accountant.md new file mode 100644 index 0000000..9fdf36b --- /dev/null +++ b/integrations/contracts/Accountant.md @@ -0,0 +1,403 @@ +# Accountant Reference + +`AccountantWithRateProviders` — tracks the vault's exchange rate, prices deposits and withdrawals across multiple assets, and accumulates protocol fees. + +--- + +## Overview + +The Accountant is the vault's pricing oracle. It holds the authoritative exchange rate — how many base-asset tokens one vault share is worth — and knows how to convert that rate into any supported quote token using per-asset rate providers. Every deposit and withdrawal routes through this contract to determine how many shares to mint or how many tokens to return. + +**Use it when you want to:** +- Read the current share price in the base asset or any supported quote token +- Check whether the Accountant is paused before attempting a deposit or withdrawal +- Preview how a rate update would affect fees +- Build off-chain tooling that shows share NAV or pending fees + +**Don't use it to:** +- Deposit or withdraw — use the [Teller](./Teller.md) instead +- Trigger exchange rate updates — that's the UPDATE_EXCHANGE_RATE_ROLE +- Claim fees — `claimFees()` must be called by the vault itself + +**Mental model:** The Accountant is a pricing engine and fee ledger. It answers one question: "what is one share worth right now, in asset X?" — and tracks what the protocol is owed as a result of that value growing. + +--- + +## Integration Guide + +### Where it fits + +``` +Accountant.updateExchangeRate() ← called periodically by keeper + │ + │ stores: exchangeRate, feesOwedInBase, highwaterMark + │ + ▼ +Accountant.getRateInQuoteSafe(asset) ← called by Teller and BoringQueue + │ + │ returns: how many `asset` tokens = 1 share + │ + ▼ +Teller uses rate to calculate shares on deposit: + shares = depositAmount * ONE_SHARE / rateInQuote + +Teller uses rate to calculate assets on withdrawal: + assetsOut = shareAmount * rateInQuote / ONE_SHARE +``` + +### Key read functions + +| Function | What it returns | +|---|---| +| `getRate()` | Share price in the base asset (raw, never reverts) | +| `getRateSafe()` | Same, but reverts if paused | +| `getRateInQuote(quote)` | Share price expressed in `quote` token units | +| `getRateInQuoteSafe(quote)` | Same, reverts if paused | +| `previewUpdateExchangeRate(newRate)` | Whether a rate update would pause the contract and what fees it would generate | + +### Checking if the Accountant is paused + +Before any deposit or withdrawal, the Teller calls `getRateInQuoteSafe()`. If the Accountant is paused, that call reverts, and the deposit or withdrawal reverts too. You can check this directly: + +```typescript +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +const publicClient = createPublicClient({ chain: mainnet, transport: http() }) +const ACCOUNTANT_ADDRESS = '0x...' as const +const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const + +const ACCOUNTANT_ABI = [ + { + name: 'getRateInQuoteSafe', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'quote', type: 'address' }], + outputs: [{ name: 'rateInQuote', type: 'uint256' }], + }, + { + name: 'getRateSafe', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: 'rate', type: 'uint256' }], + }, + { + name: 'accountantState', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [ + { name: 'payoutAddress', type: 'address' }, + { name: 'highwaterMark', type: 'uint96' }, + { name: 'feesOwedInBase', type: 'uint128' }, + { name: 'totalSharesLastUpdate', type: 'uint128' }, + { name: 'exchangeRate', type: 'uint96' }, + { name: 'allowedExchangeRateChangeUpper', type: 'uint16' }, + { name: 'allowedExchangeRateChangeLower', type: 'uint16' }, + { name: 'lastUpdateTimestamp', type: 'uint64' }, + { name: 'isPaused', type: 'bool' }, + { name: 'minimumUpdateDelayInSeconds', type: 'uint24' }, + { name: 'platformFee', type: 'uint16' }, + { name: 'performanceFee', type: 'uint16' }, + ], + }, +] as const + +// accountantState has 12 named outputs — viem returns a named object when all outputs are named. +// You can destructure directly or access by field name. +const { + isPaused, + exchangeRate, + feesOwedInBase, + highwaterMark, + lastUpdateTimestamp, + platformFee, + performanceFee, +} = await publicClient.readContract({ + address: ACCOUNTANT_ADDRESS, + abi: ACCOUNTANT_ABI, + functionName: 'accountantState', +}) +// isPaused === true means all deposits/withdrawals are currently blocked +``` + +### Reading share price in a specific asset + +```typescript +// Share price in USDC (6 decimals) +// Returns: how many USDC wei = 1 vault share +const rateInUSDC = await publicClient.readContract({ + address: ACCOUNTANT_ADDRESS, + abi: ACCOUNTANT_ABI, + functionName: 'getRateInQuoteSafe', + args: [USDC_ADDRESS], +}) + +// Share price in the base asset +const rateInBase = await publicClient.readContract({ + address: ACCOUNTANT_ADDRESS, + abi: ACCOUNTANT_ABI, + functionName: 'getRateSafe', +}) +``` + +--- + +## Full Reference + +### `getRate` + +```solidity +function getRate() public view returns (uint256 rate) +``` + +Returns the current exchange rate: how many base-asset tokens (in base-asset decimals) equal one vault share. Never reverts regardless of pause state. + +--- + +### `getRateSafe` + +```solidity +function getRateSafe() external view returns (uint256 rate) +``` + +Same as `getRate()` but reverts if the Accountant is paused. + +**Reverts:** `__Paused` + +--- + +### `getRateInQuote` + +```solidity +function getRateInQuote(ERC20 quote) public view returns (uint256 rateInQuote) +``` + +Returns the exchange rate denominated in `quote` tokens. + +**If `quote == base`:** returns `exchangeRate` directly. + +**If `quote` is pegged to base (e.g., USDC and USDT both pegged to USD):** returns `exchangeRate` adjusted for `quote`'s decimals. + +**Otherwise:** uses the per-asset `rateProvider` to convert: +``` +rateInQuote = (1 quote token in quote decimals) * exchangeRate_in_quote_decimals / quoteRate +``` +where `quoteRate` is the rate provider's output (how many base tokens = 1 quote token, in quote token decimals). + +**Reverts:** if `quote` has no `RateProviderData` configured and is not the base asset. + +--- + +### `getRateInQuoteSafe` + +```solidity +function getRateInQuoteSafe(ERC20 quote) external view returns (uint256 rateInQuote) +``` + +Same as `getRateInQuote()` but reverts if paused. This is what the Teller and BoringQueue call on every deposit and withdrawal. + +**Reverts:** `__Paused` + +--- + +### `updateExchangeRate` + +```solidity +function updateExchangeRate(uint96 newExchangeRate) external +``` + +Updates the stored exchange rate. Called periodically by a keeper with UPDATE_EXCHANGE_RATE_ROLE. + +**Behavior:** +- If the new rate is within the allowed bounds and enough time has passed since the last update: updates the rate and calculates fees owed +- If the new rate is outside bounds or the minimum delay hasn't elapsed: pauses the Accountant and stores the rate anyway (without calculating fees) + +**Pause conditions — any one triggers a pause:** +- `newExchangeRate > currentRate * allowedExchangeRateChangeUpper / 1e4` +- `newExchangeRate < currentRate * allowedExchangeRateChangeLower / 1e4` +- `block.timestamp < lastUpdateTimestamp + minimumUpdateDelayInSeconds` + +**Emits:** `ExchangeRateUpdated(oldRate, newRate, timestamp)` + +**Notes:** +- A paused Accountant blocks all deposits and withdrawals via `getRateInQuoteSafe` +- Admin must call `unpause()` to resume after investigating the anomalous rate +- Fee calculation happens only on non-pausing updates +- `newExchangeRate == 0` is **not validated**. A zero rate would pass the bounds check if `allowedExchangeRateChangeLower` is 0, and would price all deposits at infinite shares. Keepers must validate the rate value off-chain before submitting. + +--- + +### `claimFees` + +```solidity +function claimFees(ERC20 feeAsset) external +``` + +Transfers accumulated fees from the vault to `accountantState.payoutAddress`, denominated in `feeAsset`. + +**Access:** Only callable by the BoringVault itself (not directly by users or admins) + +**Parameters:** +- `feeAsset` — the token to claim fees in. Must have `RateProviderData` configured or be the base asset. + +**Reverts:** +- `__OnlyCallableByBoringVault` — caller is not the vault +- `__Paused` +- `__ZeroFeesOwed` — no fees have accrued yet + +**Emits:** `FeesClaimed(feeAsset, amount)` + +**Notes:** +- Fees are stored internally in base-asset units (`feesOwedInBase`) +- The conversion to `feeAsset` units is done at claim time using the current rate +- `feesOwedInBase` is zeroed after claiming + +--- + +### `previewUpdateExchangeRate` + +```solidity +function previewUpdateExchangeRate(uint96 newExchangeRate) + external + view + returns ( + bool updateWillPause, + uint256 newFeesOwedInBase, + uint256 totalFeesOwedInBase + ) +``` + +Previews the effect of updating the exchange rate without writing any state. Useful for off-chain keepers before submitting an update. + +**Returns:** +- `updateWillPause` — true if this update would trigger a pause +- `newFeesOwedInBase` — additional fees this update would generate (0 if pausing) +- `totalFeesOwedInBase` — total accumulated fees after this update + +--- + +### `setRateProviderData` + +```solidity +function setRateProviderData(ERC20 asset, bool isPeggedToBase, address rateProvider) external +``` + +Configures how the Accountant prices a given asset relative to the base. + +**Access:** OWNER_ROLE + +**Parameters:** +- `asset` — the ERC20 to configure +- `isPeggedToBase` — if true, the asset is treated as 1:1 with base (only decimal-adjusted); `rateProvider` is ignored +- `rateProvider` — contract implementing `IRateProvider.getRate()` that returns how many base tokens = 1 `asset` token + +**Emits:** `RateProviderUpdated(asset, isPegged, rateProvider)` + +**Notes:** +- An asset must have `RateProviderData` set before the Teller can use it for deposits/withdrawals +- Rate providers must return rates in the same decimals as `asset` +- The contract does **not** validate that `rateProvider.getRate()` returns a nonzero value. A broken or freshly deployed rate provider that returns 0 would cause division-by-zero in `getRateInQuote`, making that asset's deposits and withdrawals revert. Verify the rate provider returns a live, nonzero value before calling `setRateProviderData`. + +--- + +### `AccountantState` fields + +```solidity +struct AccountantState { + address payoutAddress; // where claimFees sends fees + uint96 highwaterMark; // highest exchange rate ever recorded + uint128 feesOwedInBase; // accumulated unpaid fees in base token units + uint128 totalSharesLastUpdate; // vault.totalSupply() at last rate update + uint96 exchangeRate; // current share price in base token (96-bit) + uint16 allowedExchangeRateChangeUpper; // max upward change per update, in bps (e.g. 10100 = +1%) + uint16 allowedExchangeRateChangeLower; // min downward change per update, in bps (e.g. 9900 = -1%) + uint64 lastUpdateTimestamp; // block.timestamp of last update + bool isPaused; + uint24 minimumUpdateDelayInSeconds; // min seconds between non-pausing updates (max 14 days) + uint16 platformFee; // annual fee in bps (max 2000 = 20%) + uint16 performanceFee; // fee on yield above highwaterMark in bps (max 5000 = 50%) +} +``` + +Read in full with `accountantState()`. + +--- + +### Fee mechanics + +**Platform fee** — a time-weighted annual fee charged on AUM regardless of performance. + +``` +shareSupply = min(totalSharesLastUpdate, currentTotalShares) +minimumAssets = shareSupply * min(newRate, currentRate) / ONE_SHARE +annualFee = minimumAssets * platformFee / 1e4 +platformFee = annualFee * timeDelta / 365 days +``` + +**Performance fee** — charged only when the exchange rate exceeds the `highwaterMark`. + +``` +yieldEarned = (newExchangeRate - highwaterMark) * shareSupply / ONE_SHARE +performanceFee = yieldEarned * performanceFee / 1e4 +``` + +When performance fees accrue, `highwaterMark` advances to `newExchangeRate` so the same yield is never double-charged. + +Both fees are denominated in base-asset units and accumulated in `feesOwedInBase`. + +--- + +### Internal Behavior + +**Pause is self-triggering.** The Accountant pauses itself when it detects a rate anomaly — it doesn't just revert. This means the rate is still updated to the new value (even an anomalous one), but fee calculation is skipped and all `Safe` view functions start reverting. A human must investigate and call `unpause()`. + +**Decimal normalization.** The `exchangeRate` is stored in base-asset decimals. When converting to a quote with different decimals, the Accountant adjusts using `_changeDecimals(amount, fromDecimals, toDecimals)`. Precision loss occurs if the base has more decimals than the quote. + +**`highwaterMark` never decreases automatically.** It only moves up when a new ATH exchange rate is recorded. Admins can call `resetHighwaterMark()` to reset it to the current rate — but only if the current rate is below the mark (e.g., after a drawdown). + +**`totalSharesLastUpdate` is used as a fee base.** On each update, the minimum of current and last share supply is used to prevent gaming by minting/burning shares between updates. + +--- + +### Edge Cases + +**Rate update too soon.** If `block.timestamp < lastUpdateTimestamp + minimumUpdateDelayInSeconds`, the update goes through but triggers a pause. The keeper should wait the minimum delay before updating again. + +**Platform fee eats all yield.** For `AccountantWithFixedRate`, if platform + performance fees exceed the yield earned above the fixed rate, the platform fee is forfeited and only the performance fee is charged. + +**Quote asset with lower decimals than base.** Precision loss occurs in the decimal normalization step. For example, if base is an 18-decimal token and quote is a 6-decimal token, any rate precision below `1e-6` is lost. For USDC or USDT as the deposit asset, this means share calculations in the Teller are slightly truncated. The effect is small per transaction but compounds at scale. Off-chain previews should use the same `_changeDecimals` logic to match on-chain behavior. + +**`claimFees` requires a vault-initiated call.** Admins cannot directly call `claimFees`. The vault must call it via `manage()`, which is gated to the Manager role. The operational path is: +```solidity +bytes memory data = abi.encodeWithSelector(Accountant.claimFees.selector, feeAssetAddress); +vault.manage(accountantAddress, data, 0); +``` +This must come from an account with MANAGER_ROLE on the vault. + +**`resetHighwaterMark` blocked when rate is above the mark.** It reverts with `__ExchangeRateAboveHighwaterMark`. This prevents resetting the mark to avoid performance fees that would otherwise be owed. + +--- + +### Common Mistakes + +1. **Calling `getRateInQuote` on an asset with no rate provider configured.** This reverts. Call `rateProviderData(asset)` first to confirm it's been set up. + +2. **Comparing `getRateInQuote(USDC)` with `getRate()` and expecting the same number.** The base rate is in base-asset decimals; the USDC rate is in 6 decimals. They are numerically different representations of the same price. + +3. **Assuming a paused Accountant means the vault is broken.** A pause is a safety circuit, not an error. The vault resumes normally once the Accountant is unpaused. + +4. **Using `getRate()` instead of `getRateInQuoteSafe(asset)` to calculate deposit amounts.** The Teller uses `getRateInQuoteSafe(depositAsset)` — use the same call to predict share amounts off-chain. For non-base assets, the rate from `getRate()` gives you the wrong number. + +5. **Not accounting for fees when calculating share NAV.** `feesOwedInBase` represents value that will leave the vault when fees are claimed. A fully accurate NAV calculation must account for this pending outflow. + +--- + +## Related Contracts + +| Contract | Relationship | +|---|---| +| [Teller](./Teller.md) | Calls `getRateInQuoteSafe(depositAsset)` and `getRateInQuoteSafe(withdrawAsset)` on every deposit and withdrawal to determine share counts | +| [BoringQueue](./BoringQueue.md) | Calls `getRateInQuoteSafe(assetOut)` at withdrawal request time to lock in the `amountOfAssets` | +| [BoringVault](./BoringVault.md) | `claimFees()` must be initiated via `vault.manage()` — the vault is the only authorized caller | diff --git a/integrations/contracts/BoringQueue.md b/integrations/contracts/BoringQueue.md new file mode 100644 index 0000000..d81a323 --- /dev/null +++ b/integrations/contracts/BoringQueue.md @@ -0,0 +1,558 @@ +# BoringQueue Reference + +`BoringOnChainQueue` — an async withdrawal queue where users submit share-redemption requests that solvers fulfill in batches. + +--- + +## Overview + +The BoringQueue handles withdrawals that cannot be processed instantly — either because the vault's assets are illiquid, or because instant withdrawals are disabled on the Teller. Users lock their shares in the queue, specify the asset they want out, accept a discount, and wait for a solver to fill the request within a defined time window. + +**Use it when you want to:** +- Withdraw shares when instant withdrawals via the Teller are unavailable +- Submit a withdraw request with a custom discount and deadline +- Cancel or replace a pending request +- Build a solver that fulfills user withdrawal requests + +**Don't use it to:** +- Make instant deposits — use the [Teller](./Teller.md) instead +- Withdraw synchronously — if the Teller has `allowWithdraws == true` for your asset, that's faster +- Read the vault's exchange rate — query the [Accountant](./Accountant.md) directly + +**Mental model:** The BoringQueue is an order book for share redemptions. Users post sell orders (with a discount to attract solvers), solvers fill those orders by providing the required assets, and users receive their tokens. + +--- + +## Integration Guide + +### Where it fits + +``` +User + │ + ▼ +BoringQueue.requestOnChainWithdraw() + │ user approves queue to spend their shares + │ shares transferred to queue + │ request stored with: assetOut, amountOfShares, amountOfAssets, maturity window + │ + │ (wait for secondsToMaturity to pass) + │ + ▼ +Solver calls BoringQueue.solveOnChainWithdraws() + │ validates: all requests mature, none expired, all same asset + │ transfers shares to solver + │ calls solver's boringSolve() callback (if solveData provided) + │ solver provides required assets + │ queue distributes assets to each user + │ + ▼ +User receives assetOut +``` + +### Key Functions + +| Function | Who calls it | What it does | +|---|---|---| +| `requestOnChainWithdraw` | End user | Submit a withdrawal request, locks shares in queue | +| `requestOnChainWithdrawWithPermit` | End user | Same but uses ERC-2612 permit for share approval | +| `cancelOnChainWithdraw` | Request owner | Cancel own request and retrieve shares | +| `replaceOnChainWithdraw` | Request owner | Cancel and resubmit with new discount/deadline | +| `solveOnChainWithdraws` | SOLVER_ROLE | Fulfill a batch of requests | +| `previewAssetsOut` | Anyone | Preview asset output for given shares + discount | +| `getRequestIds` | Anyone | List all pending request IDs | + +### Asset amount calculation + +When a request is created, the asset amount is fixed at submission time: + +``` +amountOfAssets = amountOfShares * (exchangeRate * (10000 - discount) / 10000) / ONE_SHARE +``` + +The discount is specified in basis points (e.g., `100` = 1% discount). The solver receives shares and provides `amountOfAssets` in return. + +### Request lifecycle + +``` +creationTime + │ + │← secondsToMaturity →│← secondsToDeadline →│ + │ │ │ + submitted mature expired + (not fillable) (fillable) (not fillable) +``` + +- Before maturity: request exists but solver cannot fill it +- After maturity, before deadline: solver can fill +- After deadline: request is expired and cannot be filled (user must cancel and resubmit) + +### Submit a request (step-by-step) + +1. Call `boringQueue.withdrawAssets(assetAddress)` — verify `allowWithdraws == true` +2. Note the `secondsToMaturity`, `minDiscount`, `maxDiscount`, `minimumShares`, and `minimumSecondsToDeadline` for the asset +3. Approve the **BoringQueue** to spend your vault shares (or use the permit variant) +4. Call `requestOnChainWithdraw(assetOut, amountOfShares, discount, secondsToDeadline)` + - `discount` must be between `minDiscount` and `maxDiscount` + - `secondsToDeadline` must be ≥ `minimumSecondsToDeadline` + - `amountOfShares` must be ≥ `minimumShares` +5. Save the returned `requestId` and the full `OnChainWithdraw` struct emitted in the event — you need both to cancel or reference the request later + +### Cancel a request (step-by-step) + +1. Reconstruct the `OnChainWithdraw` struct from the original `OnChainWithdrawRequested` event +2. Call `cancelOnChainWithdraw(request)` — only callable by `request.user` +3. Shares are returned to your address + +### Example (viem) + +```typescript +import { createPublicClient, createWalletClient, http, parseUnits, decodeEventLog } from 'viem' +import { mainnet } from 'viem/chains' +import { privateKeyToAccount } from 'viem/accounts' + +const QUEUE_ADDRESS = '0x...' as const +const VAULT_ADDRESS = '0x...' as const +const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const + +const account = privateKeyToAccount('0x...') + +const publicClient = createPublicClient({ chain: mainnet, transport: http() }) +const walletClient = createWalletClient({ account, chain: mainnet, transport: http() }) + +const QUEUE_ABI = [ + { + name: 'withdrawAssets', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'asset', type: 'address' }], + outputs: [ + { name: 'allowWithdraws', type: 'bool' }, + { name: 'secondsToMaturity', type: 'uint24' }, + { name: 'minimumSecondsToDeadline', type: 'uint24' }, + { name: 'minDiscount', type: 'uint16' }, + { name: 'maxDiscount', type: 'uint16' }, + { name: 'minimumShares', type: 'uint96' }, + { name: 'withdrawCapacity', type: 'uint256' }, + ], + }, + { + name: 'previewAssetsOut', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'assetOut', type: 'address' }, + { name: 'amountOfShares', type: 'uint128' }, + { name: 'discount', type: 'uint16' }, + ], + outputs: [{ name: 'amountOfAssets', type: 'uint128' }], + }, + { + name: 'requestOnChainWithdraw', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'assetOut', type: 'address' }, + { name: 'amountOfShares', type: 'uint128' }, + { name: 'discount', type: 'uint16' }, + { name: 'secondsToDeadline', type: 'uint24' }, + ], + outputs: [{ name: 'requestId', type: 'bytes32' }], + }, + { + name: 'cancelOnChainWithdraw', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'request', + type: 'tuple', + components: [ + { name: 'nonce', type: 'uint96' }, + { name: 'user', type: 'address' }, + { name: 'assetOut', type: 'address' }, + { name: 'amountOfShares', type: 'uint128' }, + { name: 'amountOfAssets', type: 'uint128' }, + { name: 'creationTime', type: 'uint40' }, + { name: 'secondsToMaturity', type: 'uint24' }, + { name: 'secondsToDeadline', type: 'uint24' }, + ], + }, + ], + outputs: [{ name: 'requestId', type: 'bytes32' }], + }, + { + name: 'OnChainWithdrawRequested', + type: 'event', + inputs: [ + { name: 'requestId', type: 'bytes32', indexed: true }, + { name: 'user', type: 'address', indexed: true }, + { name: 'assetOut', type: 'address', indexed: true }, + { name: 'nonce', type: 'uint96', indexed: false }, + { name: 'amountOfShares', type: 'uint128', indexed: false }, + { name: 'amountOfAssets', type: 'uint128', indexed: false }, + { name: 'creationTime', type: 'uint40', indexed: false }, + { name: 'secondsToMaturity', type: 'uint24', indexed: false }, + { name: 'secondsToDeadline', type: 'uint24', indexed: false }, + ], + }, +] as const + +const ERC20_ABI = [ + { + name: 'approve', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, +] as const + +// 1. Check asset config +const withdrawAsset = await publicClient.readContract({ + address: QUEUE_ADDRESS, + abi: QUEUE_ABI, + functionName: 'withdrawAssets', + args: [USDC_ADDRESS], +}) +// withdrawAsset.allowWithdraws must be true before proceeding + +// 2. Preview how many USDC you'd receive +const shareAmount = parseUnits('100', 18) // 100 vault shares (uint128 input) +const discount = 50n // 0.5% in bps — bigint required for uint16 +const secondsToDeadline = 604800n // 7 days — bigint required for uint24 + +const assetsOut = await publicClient.readContract({ + address: QUEUE_ADDRESS, + abi: QUEUE_ABI, + functionName: 'previewAssetsOut', + args: [USDC_ADDRESS, shareAmount, discount], +}) + +// 3. Approve the Queue to spend your vault shares +await walletClient.writeContract({ + address: VAULT_ADDRESS, + abi: ERC20_ABI, + functionName: 'approve', + args: [QUEUE_ADDRESS, shareAmount], +}) + +// 4. Submit request +const txHash = await walletClient.writeContract({ + address: QUEUE_ADDRESS, + abi: QUEUE_ABI, + functionName: 'requestOnChainWithdraw', + args: [USDC_ADDRESS, shareAmount, discount, secondsToDeadline], +}) + +// 5. Parse the event to recover the full OnChainWithdraw struct +// You must store this — the contract only stores the hash, not the struct itself +const receipt = await publicClient.waitForTransactionReceipt({ hash: txHash }) +const log = receipt.logs.find( + l => l.address.toLowerCase() === QUEUE_ADDRESS.toLowerCase() +) +const { args: eventArgs } = decodeEventLog({ + abi: QUEUE_ABI, + eventName: 'OnChainWithdrawRequested', + data: log!.data, + topics: log!.topics, +}) +// Store eventArgs — you need nonce, creationTime, secondsToMaturity to reconstruct +// the struct later if you want to cancel + +// 6. Cancel if needed (before a solver fills it) +// const request = { +// nonce: eventArgs.nonce, +// user: account.address, +// assetOut: USDC_ADDRESS, +// amountOfShares: shareAmount, +// amountOfAssets: eventArgs.amountOfAssets, +// creationTime: eventArgs.creationTime, +// secondsToMaturity: eventArgs.secondsToMaturity, +// secondsToDeadline: secondsToDeadline, +// } +// await walletClient.writeContract({ +// address: QUEUE_ADDRESS, +// abi: QUEUE_ABI, +// functionName: 'cancelOnChainWithdraw', +// args: [request], +// }) +``` + +--- + +## Full Reference + +### `requestOnChainWithdraw` + +```solidity +function requestOnChainWithdraw( + address assetOut, + uint128 amountOfShares, + uint16 discount, + uint24 secondsToDeadline +) external returns (bytes32 requestId) +``` + +Locks `amountOfShares` in the queue and creates a withdrawal request. The amount of assets the user will receive (`amountOfAssets`) is calculated and locked in at this point. + +**Parameters:** +- `assetOut` — the token the user wants to receive +- `amountOfShares` — shares to redeem (must be ≥ `withdrawAssets[assetOut].minimumShares`) +- `discount` — discount applied to the exchange rate in bps (must be between `minDiscount` and `maxDiscount`) +- `secondsToDeadline` — how long after maturity the request remains fillable (must be ≥ `minimumSecondsToDeadline`) + +**Returns:** `requestId` — `keccak256(abi.encode(OnChainWithdraw))` — the unique ID for this request + +**Emits:** `OnChainWithdrawRequested(requestId, user, assetOut, nonce, amountOfShares, amountOfAssets, creationTime, secondsToMaturity, secondsToDeadline)` + +**Reverts:** +- `__Paused` — queue is paused +- `__WithdrawsNotAllowedForAsset` — asset not enabled +- `__BadDiscount` — discount outside `[minDiscount, maxDiscount]` +- `__BadShareAmount` — below `minimumShares` +- `__BadDeadline` — below `minimumSecondsToDeadline` +- `__NotEnoughWithdrawCapacity` — asset's `withdrawCapacity` would be exceeded + +**Notes:** +- Requires prior `approve(QUEUE_ADDRESS, amountOfShares)` on the vault's ERC20 +- `amountOfAssets` is fixed at request time using the current exchange rate and discount +- `withdrawCapacity` for the asset is decremented by `amountOfShares` + +--- + +### `requestOnChainWithdrawWithPermit` + +```solidity +function requestOnChainWithdrawWithPermit( + address assetOut, + uint128 amountOfShares, + uint16 discount, + uint24 secondsToDeadline, + uint256 permitDeadline, + uint8 v, + bytes32 r, + bytes32 s +) external returns (bytes32 requestId) +``` + +Same as `requestOnChainWithdraw` but uses an ERC-2612 permit to approve the queue to spend vault shares in the same transaction. + +**Reverts:** +- `__PermitFailedAndAllowanceTooLow` — permit failed and existing allowance is insufficient +- All `requestOnChainWithdraw` reverts apply + +--- + +### `cancelOnChainWithdraw` + +```solidity +function cancelOnChainWithdraw(OnChainWithdraw memory request) external returns (bytes32 requestId) +``` + +Cancels the request and returns shares to `request.user`. Only callable by `request.user`. + +**Parameters:** +- `request` — the full `OnChainWithdraw` struct, which must exactly match what was stored (reconstructed from the `OnChainWithdrawRequested` event) + +**Reverts:** +- `__BadUser` — `msg.sender != request.user` +- `__RequestNotFound` — request not found in queue (wrong struct or already solved/cancelled) + +**Emits:** `OnChainWithdrawCancelled(requestId, user, timestamp)` + +**Notes:** +- `withdrawCapacity` is incremented by `request.amountOfShares` on cancellation +- Can be cancelled at any point (before or after maturity) as long as it hasn't been solved + +--- + +### `replaceOnChainWithdraw` + +```solidity +function replaceOnChainWithdraw( + OnChainWithdraw memory oldRequest, + uint16 discount, + uint24 secondsToDeadline +) external returns (bytes32 oldRequestId, bytes32 newRequestId) +``` + +Atomically cancels `oldRequest` and creates a new request with the same shares but updated `discount` and `secondsToDeadline`. Does not consume additional `withdrawCapacity`. + +**Reverts:** +- `__BadUser` — caller is not the request owner +- All `requestOnChainWithdraw` validation reverts (except capacity check) apply to the new request + +**Emits:** `OnChainWithdrawCancelled(oldRequestId, ...)` and `OnChainWithdrawRequested(newRequestId, ...)` + +--- + +### `solveOnChainWithdraws` + +```solidity +function solveOnChainWithdraws( + OnChainWithdraw[] calldata requests, + bytes calldata solveData, + address solver +) external +``` + +Fulfills a batch of withdrawal requests. All requests must be for the same `assetOut`. Called by SOLVER_ROLE. + +**Parameters:** +- `requests` — array of `OnChainWithdraw` structs to fill; must be mature and not expired +- `solveData` — arbitrary bytes passed to `solver.boringSolve()`; pass empty bytes to skip the callback +- `solver` — address receiving the shares and making the callback + +**Solve flow:** +1. Validates each request: same asset, mature, not expired +2. Dequeues all requests +3. Transfers total shares to `solver` +4. If `solveData.length > 0`, calls `solver.boringSolve(initiator, boringVault, solveAsset, totalShares, requiredAssets, solveData)` — solver must provide `requiredAssets` of `solveAsset` back to the queue before this call returns +5. Transfers `request.amountOfAssets` of `solveAsset` from `solver` to each `request.user` + +**Reverts:** +- `__Paused` — queue is paused +- `__SolveAssetMismatch` — not all requests have the same `assetOut` +- `__NotMatured` — a request hasn't reached maturity yet +- `__DeadlinePassed` — a request has expired +- `__RequestNotFound` — a request isn't in the queue + +**Emits:** `OnChainWithdrawSolved(requestId, user, timestamp)` for each request + +--- + +### `previewAssetsOut` + +```solidity +function previewAssetsOut( + address assetOut, + uint128 amountOfShares, + uint16 discount +) public view returns (uint128 amountOfAssets) +``` + +Calculates the asset amount a user would receive for the given shares and discount at the current exchange rate. + +``` +price = accountant.getRateInQuoteSafe(assetOut) * (10000 - discount) / 10000 +amountOfAssets = amountOfShares * price / ONE_SHARE +``` + +**Reverts:** `__Overflow` — result exceeds `uint128` + +--- + +### `getRequestIds` + +```solidity +function getRequestIds() public view returns (bytes32[] memory) +``` + +Returns all active request IDs currently in the queue. Includes requests that are pending, mature, and expired — but not ones that have been solved or cancelled. + +--- + +### `getRequestId` + +```solidity +function getRequestId(OnChainWithdraw calldata request) external pure returns (bytes32 requestId) +``` + +Computes the request ID for a given struct. Equivalent to `keccak256(abi.encode(request))`. + +--- + +### `WithdrawAsset` configuration + +```solidity +struct WithdrawAsset { + bool allowWithdraws; + uint24 secondsToMaturity; // max 30 days; 0 is valid — requests are fillable immediately + uint24 minimumSecondsToDeadline; // max 30 days + uint16 minDiscount; // bps, e.g. 0 = no minimum + uint16 maxDiscount; // bps, max 3000 (30%) + uint96 minimumShares; + uint256 withdrawCapacity; // rolling cap on outstanding shares; type(uint256).max = unlimited +} +``` + +Read per asset with `withdrawAssets(address assetOut)`. + +**`secondsToMaturity` can be zero.** The contract does not enforce a minimum. If it is 0, submitted requests are immediately in the fillable window — there is no waiting period. Check this field before building any UX that shows users a "wait time" before their request can be filled. + +--- + +### `OnChainWithdraw` struct + +```solidity +struct OnChainWithdraw { + uint96 nonce; // auto-assigned, ensures unique request IDs + address user; // msg.sender at request time + address assetOut; // token to receive + uint128 amountOfShares; // shares locked in queue + uint128 amountOfAssets; // assets solver must provide (fixed at request time) + uint40 creationTime; // block.timestamp at request time + uint24 secondsToMaturity; // from withdrawAssets config at request time + uint24 secondsToDeadline; // user-specified, >= minimumSecondsToDeadline +} +``` + +The request ID is `keccak256(abi.encode(OnChainWithdraw))`. You must store the entire struct to cancel, replace, or reference a request — the contract does not store the struct, only its hash. + +--- + +### Internal Behavior + +**`withdrawCapacity`:** A per-asset rolling cap on the total shares outstanding in the queue. Decremented on new requests, incremented on cancellations. **Solver fills do not restore capacity** — it is one-way consumption. After successful solves, the admin must manually call `setWithdrawCapacity` to replenish it, or new requests will revert with `__NotEnoughWithdrawCapacity`. Plan for this in operational runbooks. + +**`amountOfAssets` is fixed at request time:** The solver knows exactly how many assets to provide before filling. Exchange rate changes between submission and solve do not affect the payout to users. + +**Shares leave the user's wallet at request time:** The queue holds shares in custody. Users cannot transfer or use those shares while the request is open. + +**Solver callback is optional:** If `solveData` is empty, the callback is skipped and the solver must have pre-approved the queue to spend `requiredAssets` before calling `solveOnChainWithdraws`. + +**Request ID uniqueness:** Request IDs are computed as `keccak256(abi.encode(OnChainWithdraw))`. The `nonce` field (auto-incremented from contract state) makes it practically impossible to generate two requests with the same ID. + +--- + +### Edge Cases + +**Expired requests can't be filled or auto-cancelled.** If a request passes its deadline, the solver cannot fill it. The user must call `cancelOnChainWithdraw` to get their shares back. + +**Exchange rate movement between request and solve.** `amountOfAssets` is locked at submission time using the current exchange rate. If the exchange rate rises after submission, users receive fewer assets than a fresh withdrawal would give — the locked amount does not update. If the rate drops, users are protected (they get the higher locked-in value, solver absorbs the difference). Encourage users to cancel and resubmit if the rate has moved materially in their favour before a solver fills the request. + +**Discount range is validated at request time.** If the admin changes `minDiscount`/`maxDiscount` after a request is submitted, the existing request is unaffected — it remains in the queue at its original discount. + +**Multiple requests with the same parameters.** If two requests are submitted with identical parameters in the same block, they would have different nonces and thus different request IDs. Keccak256 collision is theoretically impossible. + +**`withdrawCapacity == 0`.** Admin can set this to stop new requests for an asset without disabling existing ones. Existing requests remain fillable. + +--- + +### Common Mistakes + +1. **Storing only `requestId` and not the full struct.** You need the complete `OnChainWithdraw` struct to cancel or replace a request. Parse and store it from the `OnChainWithdrawRequested` event. + +2. **Approving the wrong address for shares.** Approve the **BoringQueue**, not the Teller or vault, to spend vault shares. + +3. **Submitting a discount outside the asset's allowed range.** Call `withdrawAssets(assetOut)` first and respect `minDiscount`/`maxDiscount`. + +4. **Calling `cancelOnChainWithdraw` from a different address than the request's `user`.** The queue enforces `msg.sender == request.user`. + +5. **Assuming a request will be filled before the deadline.** Solvers are not guaranteed to fill any request. If liquidity is low or the discount is unattractive, the request may expire. Build UI flows that let users monitor and replace expired requests. + +6. **Not accounting for `withdrawCapacity`**. If capacity is 0 for an asset, `requestOnChainWithdraw` will revert with `__NotEnoughWithdrawCapacity`. + +--- + +## Related Contracts + +| Contract | Relationship | +|---|---| +| [BoringVault](./BoringVault.md) | Queue holds vault shares in custody; solver receives shares from vault's ERC20 | +| [Accountant](./Accountant.md) | Queue calls `accountant.getRateInQuoteSafe(assetOut)` to calculate `amountOfAssets` at request time | +| [Teller](./Teller.md) | The default solver (`BoringSolver`) calls `teller.bulkWithdraw()` to convert shares to assets for fulfillment | diff --git a/integrations/contracts/BoringVault.md b/integrations/contracts/BoringVault.md new file mode 100644 index 0000000..0d422b6 --- /dev/null +++ b/integrations/contracts/BoringVault.md @@ -0,0 +1,307 @@ +# BoringVault Reference + +`BoringVault` — the core custody contract. Holds all protocol assets and issues ERC20 shares. + +--- + +## Overview + +The BoringVault is a minimal, auth-gated token vault. It holds every asset the protocol manages, mints shares when assets come in, burns shares when assets go out, and executes arbitrary calls against external protocols on behalf of the vault's strategy. + +**Use it when you want to:** +- Read a user's share balance or the total share supply +- Verify the vault's address before setting approvals +- Transfer vault shares directly (e.g., as a permissioned operator) +- Understand how the protocol executes strategy calls + +**Don't use it to:** +- Deposit or withdraw directly — use the [Teller](./Teller.md) instead +- Read the share price — query the [Accountant](./Accountant.md) instead +- Submit withdrawal requests — use [BoringQueue](./BoringQueue.md) + +**Mental model:** The BoringVault is a locked safe with a very specific set of keys. The Teller holds the deposit/withdraw key. The Manager holds the strategy key. Nobody else can move assets. + +--- + +## Integration Guide + +### Where it fits + +``` +External users + │ + ▼ + Teller ──────────────────────────► BoringVault.enter() → mint shares + Teller ◄──────────────────────── BoringVault.exit() → burn shares + + Manager ─────────────────────────► BoringVault.manage() → execute strategy + + BoringQueue / BoringSolver ──────► BoringVault ERC20 transfers (shares move) +``` + +As an integrator, you will almost never call BoringVault directly. You interact with the Teller for deposits and withdrawals. The vault is relevant to you primarily as the **approval target** for deposit tokens and the **ERC20 token address** for vault shares. + +### Key addresses to know + +| What | Where | +|---|---| +| Approve token for deposit | `vault.address` — the Teller's `vault()` immutable | +| Vault share ERC20 | `vault.address` — the vault itself is the share token | +| Approve queue for share withdrawal | `vault.address` | + +### Reading vault state + +```typescript +import { createPublicClient, http } from 'viem' +import { mainnet } from 'viem/chains' + +const publicClient = createPublicClient({ chain: mainnet, transport: http() }) +const VAULT_ADDRESS = '0x...' as const +const USER_ADDRESS = '0x...' as const + +const BORING_VAULT_ABI = [ + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ name: '', type: 'uint256' }], + }, + { + name: 'totalSupply', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint256' }], + }, + { + name: 'decimals', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'uint8' }], + }, + { + name: 'hook', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ name: '', type: 'address' }], + }, +] as const + +// Share balance +const shares = await publicClient.readContract({ + address: VAULT_ADDRESS, + abi: BORING_VAULT_ABI, + functionName: 'balanceOf', + args: [USER_ADDRESS], +}) + +// Total shares in circulation +const totalSupply = await publicClient.readContract({ + address: VAULT_ADDRESS, + abi: BORING_VAULT_ABI, + functionName: 'totalSupply', +}) + +// Vault share decimals (used to calculate ONE_SHARE = 10n ** decimals) +const decimals = await publicClient.readContract({ + address: VAULT_ADDRESS, + abi: BORING_VAULT_ABI, + functionName: 'decimals', +}) + +// Current before-transfer hook (usually the Teller) +const hook = await publicClient.readContract({ + address: VAULT_ADDRESS, + abi: BORING_VAULT_ABI, + functionName: 'hook', +}) +``` + +--- + +## Full Reference + +### `enter` + +```solidity +function enter( + address from, + ERC20 asset, + uint256 assetAmount, + address to, + uint256 shareAmount +) external +``` + +Pulls `assetAmount` of `asset` from `from` into the vault, then mints `shareAmount` shares to `to`. If `assetAmount == 0`, no token transfer happens — only shares are minted. + +**Access:** MINTER_ROLE (held by the Teller) + +**Parameters:** +- `from` — address tokens are pulled from; must have approved the vault for `assetAmount` +- `asset` — ERC20 token to accept +- `assetAmount` — amount of `asset` to transfer in +- `to` — address that receives the minted shares +- `shareAmount` — number of shares to mint + +**Emits:** `Enter(from, asset, assetAmount, to, shareAmount)` + +**Notes:** +- The Teller pre-calculates `shareAmount` using the Accountant's exchange rate before calling this +- `from` and `to` can differ — this is how the Teller deposits on behalf of a user +- The vault does **not** validate that `shareAmount > 0`. Minting zero shares while pulling real tokens is possible if the caller (Teller) supplies a zero value. This would burn user funds without issuing any shares. A correctly configured Teller prevents this via the `__ZeroAssets` / `__MinimumMintNotMet` checks before calling `enter`. + +--- + +### `exit` + +```solidity +function exit( + address to, + ERC20 asset, + uint256 assetAmount, + address from, + uint256 shareAmount +) external +``` + +Burns `shareAmount` shares from `from`, then sends `assetAmount` of `asset` to `to`. If `assetAmount == 0`, no token transfer happens — only shares are burned. + +**Access:** BURNER_ROLE (held by the Teller) + +**Parameters:** +- `to` — address that receives the withdrawn assets +- `asset` — ERC20 token to send out +- `assetAmount` — amount to transfer out +- `from` — address whose shares are burned +- `shareAmount` — number of shares to burn + +**Emits:** `Exit(to, asset, assetAmount, from, shareAmount)` + +**Notes:** +- Unlike ERC20 transfers, burning shares from `from` does not require `from` to have approved the vault — the vault calls `_burn` directly on its own internal balances +- The Teller pre-calculates `assetAmount` using the Accountant before calling this + +--- + +### `manage` (single call) + +```solidity +function manage( + address target, + bytes calldata data, + uint256 value +) external returns (bytes memory result) +``` + +Executes an arbitrary call from the vault to `target` with `data` and `value` ETH. Used by the Manager to interact with external DeFi protocols (e.g., supply assets to Aave, provide liquidity to Uniswap). + +**Access:** MANAGER_ROLE + +**Returns:** raw `bytes` return value from the call + +--- + +### `manage` (batch) + +```solidity +function manage( + address[] calldata targets, + bytes[] calldata data, + uint256[] calldata values +) external returns (bytes[] memory results) +``` + +Executes multiple calls in a single transaction. All arrays must have the same length. + +**Access:** MANAGER_ROLE + +--- + +### `setBeforeTransferHook` + +```solidity +function setBeforeTransferHook(address _hook) external +``` + +Sets the contract that receives a callback before every share transfer. The hook is called as: + +```solidity +hook.beforeTransfer(from, to, msg.sender) +``` + +In a standard deployment, the Teller is set as the hook, enforcing share locks and deny lists on all share movements. + +**Access:** OWNER_ROLE + +**Notes:** +- Setting `_hook` to `address(0)` disables the hook entirely — shares become freely transferable with no restrictions +- The hook is called on `transfer()` and `transferFrom()` but not on `_mint` or `_burn` + +--- + +### ERC20 functions + +The vault is itself an ERC20. Standard functions — `transfer`, `transferFrom`, `approve`, `permit`, `balanceOf`, `totalSupply`, `allowance`, `decimals`, `name`, `symbol` — all work as expected. + +Every `transfer` and `transferFrom` calls the `beforeTransfer` hook (if set) before executing. + +```solidity +function transfer(address to, uint256 amount) public override returns (bool) +function transferFrom(address from, address to, uint256 amount) public override returns (bool) +``` + +Both internally call `_callBeforeTransfer(from, to)` which invokes `hook.beforeTransfer(from, to, msg.sender)`. + +--- + +### Internal Behavior + +**The vault does not track which assets it holds.** It has no internal registry of what tokens are deposited. The Accountant determines the exchange rate based on off-chain strategy reporting. The vault simply holds whatever tokens are sent to it. + +**`manage()` is fully unconstrained at the vault level.** The vault will execute any call the Manager sends. Access control over which calls are allowed is enforced by the Decoder/Sanitizer layer in the Manager contract — not in the vault itself. + +**`enter` and `exit` do not validate the share price or amounts.** The vault trusts the Teller to have already checked the exchange rate via the Accountant. A misconfigured or malicious Teller with MINTER_ROLE could call `enter` with any `shareAmount`, including zero — the vault would execute it without complaint. The security boundary is entirely in who holds MINTER_ROLE and BURNER_ROLE. + +**The vault can receive ETH.** A `receive()` function is present, so ETH sent directly to the vault is accepted. This is used when strategy calls return ETH. + +**ERC721 and ERC1155 support.** The vault implements `ERC721Holder` and `ERC1155Holder`, so it can receive NFTs and multi-tokens from strategy positions. + +--- + +### Edge Cases + +**Shares can be minted without transferring assets.** If `assetAmount == 0` in `enter()`, no tokens are pulled but shares are still minted. This is used for initialization or cross-chain bridging scenarios. + +**Assets can be transferred out without burning shares.** If `assetAmount == 0` in `exit()`, no tokens leave but shares are still burned. Used for specific accounting scenarios. + +**Hook set to address(0).** If `setBeforeTransferHook(address(0))` is called, all share transfers become unrestricted. The share lock, deny list, and permissioned transfer controls from the Teller are bypassed entirely. + +**Reentrancy.** The vault does not have a reentrancy guard. The Teller and other callers are expected to guard their own flows. The `manage()` function can trigger callbacks into external contracts. + +--- + +### Common Mistakes + +1. **Approving the Teller instead of the Vault.** Deposit tokens must be approved to the **vault** address. The Teller calls `vault.enter()`, which calls `safeTransferFrom(user, vault, amount)`. + +2. **Approving the wrong address for queue withdrawals.** Vault shares must be approved to the **BoringQueue** address, not the Teller. + +3. **Treating vault shares as a wrapped asset with 1:1 redemption.** Share value grows over time as the exchange rate increases. `1 share ≠ 1 base token` after any yield has accrued. + +4. **Calling `enter` or `exit` directly.** These are auth-gated to specific roles. Direct calls from user addresses will revert. + +5. **Triggering `claimFees` directly.** `Accountant.claimFees()` enforces `msg.sender == vault`. It cannot be called by an EOA or admin directly. The operational path is: construct the `claimFees(feeAsset)` calldata, then submit it via `vault.manage(accountantAddress, calldata, 0)` from an account holding MANAGER_ROLE. + +--- + +## Related Contracts + +| Contract | Relationship | +|---|---| +| [Teller](./Teller.md) | Holds MINTER_ROLE and BURNER_ROLE; calls `enter()` and `exit()` | +| [Accountant](./Accountant.md) | Provides the exchange rate that the Teller uses to calculate share amounts before calling `enter`/`exit` | +| [BoringQueue](./BoringQueue.md) | Holds vault shares in custody for pending withdrawal requests; BoringSolver calls `teller.bulkWithdraw()` which calls `exit()` | diff --git a/integrations/contracts/Teller.md b/integrations/contracts/Teller.md new file mode 100644 index 0000000..7437bd0 --- /dev/null +++ b/integrations/contracts/Teller.md @@ -0,0 +1,469 @@ +# Teller Reference + +`TellerWithMultiAssetSupport` — the single entry point for all deposits and withdrawals into a BoringVault. + +--- + +## Overview + +The Teller accepts ERC20 tokens (or native ETH) from users, calculates the correct share amount using the Accountant's exchange rate, and instructs the vault to mint shares. On the way out, it burns shares and releases assets. + +**Use it when you want to:** +- Deposit a supported asset and receive vault shares +- Withdraw shares for a supported asset immediately (if enabled) +- Integrate referral tracking into deposits +- Check whether an asset is supported for deposit or withdrawal + +**Don't use it to:** +- Withdraw asynchronously from an illiquid vault — use [BoringQueue](./BoringQueue.md) instead +- Read the current exchange rate — query the [Accountant](./Accountant.md) directly +- Execute vault strategies — that's the Manager role + +**Mental model:** The Teller is the vault's front desk. It validates your entry, sets a temporary share lock on the way in, and stamps your exit on the way out. + +--- + +## Integration Guide + +### Where it fits + +``` +User + │ + ▼ +Teller.deposit() ← you call this + │ checks: not paused, asset allowed, cap not hit + │ calculates shares via Accountant.getRateInQuoteSafe() + │ + ▼ +BoringVault.enter() ← Teller calls this + │ pulls asset from user → vault + │ mints shares to receiver + │ + ▼ +User receives vault shares (locked for shareLockPeriod) +``` + +For withdrawals: + +``` +User + │ + ▼ +Teller.withdraw() + │ checks: not paused, asset allowed for withdraws + │ calculates assets out via Accountant.getRateInQuoteSafe() + │ + ▼ +BoringVault.exit() + │ burns shares from user + │ transfers asset to destination + │ + ▼ +User receives asset +``` + +### Key Functions + +| Function | Who calls it | What it does | +|---|---|---| +| `deposit` | End user | Deposit ERC20 or ETH, receive shares | +| `depositWithPermit` | End user | Deposit using ERC-2612 permit instead of prior approval | +| `bulkDeposit` | SOLVER_ROLE | Batch deposit on behalf of users | +| `bulkWithdraw` | SOLVER_ROLE | Batch withdraw on behalf of users | +| `withdraw` | End user | Withdraw shares for asset immediately | +| `refundDeposit` | DEPOSIT_REFUNDER_ROLE | Cancel a deposit that is still within its lock period | + +**Share calculation:** +``` +shares = depositAmount * ONE_SHARE / getRateInQuoteSafe(depositAsset) +``` +If the asset has a `sharePremium` configured (in basis points): +``` +shares = shares * (10000 - sharePremium) / 10000 +``` + +**Asset calculation on withdrawal:** +``` +assetsOut = shareAmount * getRateInQuoteSafe(withdrawAsset) / ONE_SHARE +``` + +### Auth requirement + +`deposit`, `depositWithPermit`, and `withdraw` all carry `requiresAuth`. The constructor sets no default Authority (`Authority(address(0))`), which means only the owner can call these functions out of the box. A production deployment must configure an Authority contract (or set the owner appropriately) that grants access to all intended callers — including regular users. If this is misconfigured, all user-facing calls silently revert with no meaningful error. Confirm the Auth setup before integrating. + +### Deposit step-by-step + +1. Call `teller.assetData(tokenAddress)` — verify `allowDeposits == true` +2. Approve the **vault** (not the Teller) to spend your deposit token +3. Call `teller.deposit(tokenAddress, amount, minimumSharesOut, referralAddress)` +4. Shares are minted to your address and locked for `shareLockPeriod` seconds +5. After the lock expires, shares are freely transferable + +For native ETH: skip the approval, send ETH as `msg.value`, pass `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` as `depositAsset`. + +### Withdraw step-by-step + +1. Call `teller.assetData(tokenAddress)` — verify `allowWithdraws == true` +2. Confirm your share lock period has expired (`beforeTransferData[yourAddress].shareUnlockTime < block.timestamp`) +3. Call `teller.withdraw(tokenAddress, shareAmount, minimumAssetsOut, recipient)` +4. Shares are burned, assets are sent to `recipient` + +### Example (viem) + +```typescript +import { createPublicClient, createWalletClient, http, parseUnits } from 'viem' +import { mainnet } from 'viem/chains' +import { privateKeyToAccount } from 'viem/accounts' + +const VAULT_ADDRESS = '0x...' as const +const TELLER_ADDRESS = '0x...' as const +const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as const +const USDC_DECIMALS = 6 + +// Replace with your actual account — wagmi/RainbowKit provide this in a browser context +const account = privateKeyToAccount('0x...') + +const publicClient = createPublicClient({ chain: mainnet, transport: http() }) +const walletClient = createWalletClient({ account, chain: mainnet, transport: http() }) + +const TELLER_ABI = [ + { + name: 'assetData', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'asset', type: 'address' }], + outputs: [ + { name: 'allowDeposits', type: 'bool' }, + { name: 'allowWithdraws', type: 'bool' }, + { name: 'sharePremium', type: 'uint16' }, + ], + }, + { + name: 'deposit', + type: 'function', + stateMutability: 'payable', + inputs: [ + { name: 'depositAsset', type: 'address' }, + { name: 'depositAmount', type: 'uint256' }, + { name: 'minimumMint', type: 'uint256' }, + { name: 'referralAddress', type: 'address' }, + ], + outputs: [{ name: 'shares', type: 'uint256' }], + }, + { + name: 'withdraw', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'withdrawAsset', type: 'address' }, + { name: 'shareAmount', type: 'uint256' }, + { name: 'minimumAssets', type: 'uint256' }, + { name: 'to', type: 'address' }, + ], + outputs: [{ name: 'assetsOut', type: 'uint256' }], + }, +] as const + +const ERC20_ABI = [ + { + name: 'approve', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'spender', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + outputs: [{ name: '', type: 'bool' }], + }, +] as const + +// 1. Check asset is supported +const assetData = await publicClient.readContract({ + address: TELLER_ADDRESS, + abi: TELLER_ABI, + functionName: 'assetData', + args: [USDC_ADDRESS], +}) +// assetData.allowDeposits must be true before proceeding + +// 2. Approve the VAULT (not the Teller) to spend USDC +await walletClient.writeContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: 'approve', + args: [VAULT_ADDRESS, parseUnits('1000', USDC_DECIMALS)], +}) + +// 3. Deposit +const depositAmount = parseUnits('1000', USDC_DECIMALS) +// Never pass 0n in production - exposes the tx to sandwich attacks. +// Expected shares = depositAmount * ONE_SHARE / getRateInQuoteSafe(asset). +// Apply a slippage tolerance before using as minimumMint. +const minimumShares = 0n + +const txHash = await walletClient.writeContract({ + address: TELLER_ADDRESS, + abi: TELLER_ABI, + functionName: 'deposit', + args: [USDC_ADDRESS, depositAmount, minimumShares, '0x0000000000000000000000000000000000000000'], +}) + +// 4. Withdraw later (after share lock expires) +const shareAmount = parseUnits('990', 18) +const minimumAssets = parseUnits('985', USDC_DECIMALS) + +await walletClient.writeContract({ + address: TELLER_ADDRESS, + abi: TELLER_ABI, + functionName: 'withdraw', + args: [USDC_ADDRESS, shareAmount, minimumAssets, account.address], +}) +``` + +--- + +## Full Reference + +### `deposit` + +```solidity +function deposit( + ERC20 depositAsset, + uint256 depositAmount, + uint256 minimumMint, + address referralAddress +) external payable returns (uint256 shares) +``` + +Deposits an ERC20 token or native ETH into the vault and mints shares to `msg.sender`. + +**Parameters:** +- `depositAsset` — ERC20 token address, or `0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE` for native ETH +- `depositAmount` — amount of `depositAsset` to deposit (ignored for native ETH; `msg.value` is used instead) +- `minimumMint` — minimum shares to receive; reverts if shares calculated fall below this +- `referralAddress` — tracked in the `Deposit` event; pass `address(0)` if unused + +**Returns:** `shares` — shares minted to `msg.sender` + +**Emits:** `Deposit(nonce, receiver, depositAsset, depositAmount, shareAmount, depositTimestamp, shareLockPeriod, referralAddress)` + +**Reverts:** +- `__Paused` — contract is paused +- `__AssetNotSupported` — `assetData[depositAsset].allowDeposits == false` +- `__ZeroAssets` — `depositAmount == 0` (or `msg.value == 0` for ETH) +- `__MinimumMintNotMet` — calculated shares < `minimumMint` +- `__DepositExceedsCap` — `shares + vault.totalSupply() > depositCap` +- `__DualDeposit` — ERC20 deposit sent with nonzero `msg.value` +- `__TransferDenied` — `msg.sender` or receiver is on a deny list + +**Notes:** +- Requires prior `approve(vault, depositAmount)` for ERC20 deposits +- For native ETH: send ETH as `msg.value`, no approval required +- Shares are locked to `msg.sender` for `shareLockPeriod` seconds after minting +- The `requiresAuth` modifier means the contract's Auth configuration must permit `msg.sender` to call this function + +--- + +### `depositWithPermit` + +```solidity +function depositWithPermit( + ERC20 depositAsset, + uint256 depositAmount, + uint256 minimumMint, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s, + address referralAddress +) external returns (uint256 shares) +``` + +Same as `deposit` but uses an ERC-2612 permit signature to set the vault's allowance in the same transaction. The permit approves the **vault** to spend `depositAsset`. + +**Reverts:** +- `__PermitFailedAndAllowanceTooLow` — permit call failed and existing allowance is insufficient +- `__CannotDepositNative` — native ETH is not supported via this function +- All `deposit` reverts apply + +--- + +### `bulkDeposit` + +```solidity +function bulkDeposit( + ERC20 depositAsset, + uint256 depositAmount, + uint256 minimumMint, + address to +) external returns (uint256 shares) +``` + +Deposits on behalf of a recipient without emitting the public deposit history or setting a share lock. Called by SOLVER_ROLE. + +**Parameters:** +- `to` — address that receives the minted shares + +**Emits:** `BulkDeposit(asset, depositAmount)` + +**Notes:** +- Shares minted via `bulkDeposit` are **immediately transferable** — no share lock is applied +- These deposits are **non-refundable** — no entry is written to `publicDepositHistory`, so `refundDeposit` cannot be used + +--- + +### `bulkWithdraw` + +```solidity +function bulkWithdraw( + ERC20 withdrawAsset, + uint256 shareAmount, + uint256 minimumAssets, + address to +) external returns (uint256 assetsOut) +``` + +Burns shares from `msg.sender` (the solver) and sends assets to `to`. Called by SOLVER_ROLE as part of the BoringQueue solve flow. + +**Emits:** `BulkWithdraw(asset, shareAmount)` + +--- + +### `withdraw` + +```solidity +function withdraw( + ERC20 withdrawAsset, + uint256 shareAmount, + uint256 minimumAssets, + address to +) external returns (uint256 assetsOut) +``` + +Burns `shareAmount` from `msg.sender` and sends `withdrawAsset` to `to`. + +**Parameters:** +- `withdrawAsset` — token to receive +- `shareAmount` — shares to burn +- `minimumAssets` — minimum assets to receive; reverts if below +- `to` — recipient of the withdrawn assets + +**Reverts:** +- `__Paused` — contract is paused +- `__AssetNotSupported` — `assetData[withdrawAsset].allowWithdraws == false` +- `__ZeroShares` — `shareAmount == 0` +- `__MinimumAssetsNotMet` — calculated assets < `minimumAssets` +- `__SharesAreLocked` — caller's share lock has not expired +- `__TransferDenied` — caller is on the deny list + +**Emits:** `Withdraw(asset, shareAmount)` + +--- + +### `refundDeposit` + +```solidity +function refundDeposit( + uint256 nonce, + address receiver, + address depositAsset, + uint256 depositAmount, + uint256 shareAmount, + uint256 depositTimestamp, + uint256 shareLockUpPeriodAtTimeOfDeposit, + address referralAddress +) external +``` + +Cancels a pending deposit and returns assets to the receiver. All parameters must exactly match those recorded at deposit time (they reconstruct the stored `publicDepositHistory` hash). + +**Reverts:** +- `__SharesAreUnLocked` — lock period has already elapsed; too late to refund +- `__BadDepositHash` — provided parameters don't match the stored deposit record + +**Emits:** `DepositRefunded(nonce, depositHash, user)` + +**Notes:** +- The deposit history hash is deleted after refund to prevent replay +- If the original deposit used native ETH, the refund asset is the wrapped native token (WETH) +- Callable by DEPOSIT_REFUNDER_ROLE / STRATEGIST_MULTISIG_ROLE + +--- + +### `beforeTransfer` (hook) + +```solidity +function beforeTransfer(address from, address to, address operator) public view +``` + +Called by BoringVault on every share transfer. Reverts if any of the following are true: +- `from` is on the denyFrom list +- `to` is on the denyTo list +- `operator` is on the denyOperator list +- `permissionedTransfers == true` and `operator` is not a permissioned operator +- `from`'s `shareUnlockTime > block.timestamp` + +--- + +### View functions and state + +| Query | What it returns | +|---|---| +| `assetData(address)` | `Asset { allowDeposits, allowWithdraws, sharePremium }` | +| `depositCap()` | Max total shares (uint112). `type(uint112).max` = unlimited | +| `shareLockPeriod()` | Seconds shares are locked after deposit | +| `isPaused()` | Whether the contract is paused. Pausing blocks **both** deposits and withdrawals — despite the contract comments describing it as a deposit-only pause, `_withdraw()` also checks `isPaused` | +| `beforeTransferData(address)` | Deny flags and `shareUnlockTime` for an address | +| `publicDepositHistory(nonce)` | keccak256 hash of deposit parameters for refund validation | +| `depositNonce()` | Current deposit counter | + +--- + +### Internal Behavior + +**Approval target:** The vault (not the Teller) pulls the deposit token using `safeTransferFrom(user, vault, amount)`. Users must approve the **vault address**, not the Teller. + +**Share lock:** After every public deposit, `beforeTransferData[receiver].shareUnlockTime` is set to `block.timestamp + shareLockPeriod`. All share transfers from that address revert until the lock expires. The lock only applies if `shareLockPeriod > 0`. + +**Deposit cap:** Enforced as `shares + vault.totalSupply() > depositCap`. No partial fills — the entire deposit reverts if it would exceed the cap. + +**Share premium:** A per-asset haircut on shares minted, expressed in basis points. A premium of 40 means the user receives 0.4% fewer shares. This is used to account for deposit/withdrawal spread on illiquid assets. + +**Native ETH flow:** The Teller wraps ETH via WETH, approves the vault, and calls `vault.enter(teller, WETH, amount, receiver, shares)`. The deposit history records the original asset as `NATIVE` but any refund returns WETH. + +--- + +### Edge Cases + +**Double-deposit with shorter lock:** If the `shareLockPeriod` is decreased, a user who already has a pending lock can make a new deposit (even 1 wei) and their unlock time resets to the shorter period. Their original deposit becomes refundable via `refundDeposit` as long as the original lock period hasn't expired. + +**Permit reverting silently:** `depositWithPermit` catches permit failures and checks the existing allowance. If the allowance is sufficient, the deposit proceeds without a permit. Only reverts with `__PermitFailedAndAllowanceTooLow` if the allowance is also insufficient. + +**Zero-value share lock:** If `shareLockPeriod == 0`, deposits do not record to `publicDepositHistory` and shares are immediately transferable. Deposits are also non-refundable. + +**Permissioned transfers:** If `permissionedTransfers == true`, only addresses explicitly added as permissioned operators can transfer shares. This affects all transfers, not just deposits. + +--- + +### Common Mistakes + +1. **Approving the Teller instead of the Vault.** The Teller never pulls tokens directly. The Vault does. Approve `vault.address`. + +2. **Trying to transfer or withdraw shares before the lock expires.** Check `beforeTransferData[yourAddress].shareUnlockTime` before initiating any share movement. + +3. **Passing `depositAmount = 0` for native ETH deposits.** The `depositAmount` parameter is ignored for ETH — only `msg.value` matters. + +4. **Setting `minimumMint = 0` in production.** This exposes deposits to sandwich attacks. Calculate the expected shares off-chain and apply a slippage tolerance. + +5. **Calling `withdraw` when the asset is not enabled for withdrawals.** Check `assetData[asset].allowWithdraws` first. Some assets are deposit-only and require going through the BoringQueue. + +--- + +## Related Contracts + +| Contract | Relationship | +|---|---| +| [BoringVault](./BoringVault.md) | Teller calls `vault.enter()` and `vault.exit()` to mint/burn shares | +| [Accountant](./Accountant.md) | Teller calls `accountant.getRateInQuoteSafe(asset)` to price every deposit and withdrawal | +| [BoringQueue](./BoringQueue.md) | Alternative withdrawal path for async exits; calls `teller.bulkWithdraw()` via the solver |